meshai/tests/test_pipeline_inhibitor_grouper.py

194 lines
6.8 KiB
Python
Raw Normal View History

"""Tests for Phase 2.2 inhibitor and grouper."""
import pytest
from unittest.mock import Mock
from meshai.notifications.events import Event
from meshai.notifications.pipeline.inhibitor import Inhibitor
from meshai.notifications.pipeline.grouper import Grouper
def make_event(id, severity, inhibit_keys=None, group_key=None):
return Event(
id=id,
source="test",
category="test_cat",
severity=severity,
title=f"Event {id}",
inhibit_keys=inhibit_keys or [],
group_key=group_key,
)
# ===================== INHIBITOR TESTS =====================
class TestInhibitor:
def test_event_without_inhibit_keys_passes_through(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler)
event = make_event("e1", "immediate", inhibit_keys=[])
inhibitor.handle(event)
next_handler.assert_called_once_with(event)
def test_lower_severity_after_higher_is_suppressed(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler)
ev1 = make_event("e1", "immediate", inhibit_keys=["battery:NODE1"])
ev2 = make_event("e2", "priority", inhibit_keys=["battery:NODE1"])
inhibitor.handle(ev1)
inhibitor.handle(ev2)
assert next_handler.call_count == 1
next_handler.assert_called_once_with(ev1)
def test_equal_severity_is_suppressed(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler)
ev1 = make_event("e1", "priority", inhibit_keys=["key1"])
ev2 = make_event("e2", "priority", inhibit_keys=["key1"])
inhibitor.handle(ev1)
inhibitor.handle(ev2)
assert next_handler.call_count == 1
def test_higher_severity_after_lower_passes_and_upgrades(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler)
ev1 = make_event("e1", "priority", inhibit_keys=["key1"])
ev2 = make_event("e2", "immediate", inhibit_keys=["key1"])
inhibitor.handle(ev1)
inhibitor.handle(ev2)
assert next_handler.call_count == 2
keys = inhibitor.active_keys()
assert keys["key1"][0] == 2 # immediate rank
def test_inhibit_key_expires_after_ttl(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler, ttl_seconds=10)
current_time = [0.0]
inhibitor._now = lambda: current_time[0]
ev1 = make_event("e1", "immediate", inhibit_keys=["key1"])
current_time[0] = 0.0
inhibitor.handle(ev1)
current_time[0] = 15.0
ev2 = make_event("e2", "routine", inhibit_keys=["key1"])
inhibitor.handle(ev2)
assert next_handler.call_count == 2
def test_multiple_keys_any_active_suppresses(self):
next_handler = Mock()
inhibitor = Inhibitor(next_handler)
ev1 = make_event("e1", "immediate", inhibit_keys=["a", "b"])
inhibitor.handle(ev1)
assert next_handler.call_count == 1
ev2 = make_event("e2", "routine", inhibit_keys=["b", "c"])
inhibitor.handle(ev2)
assert next_handler.call_count == 1 # suppressed by "b"
ev3 = make_event("e3", "routine", inhibit_keys=["c", "d"])
inhibitor.handle(ev3)
assert next_handler.call_count == 2 # passes, no active key
# ===================== GROUPER TESTS =====================
class TestGrouper:
def test_event_without_group_key_emits_immediately(self):
next_handler = Mock()
grouper = Grouper(next_handler)
event = make_event("e1", "immediate", group_key=None)
grouper.handle(event)
next_handler.assert_called_once_with(event)
def test_event_with_group_key_is_held_not_emitted(self):
next_handler = Mock()
grouper = Grouper(next_handler)
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
event = make_event("e1", "priority", group_key="fire:42")
grouper.handle(event)
next_handler.assert_not_called()
assert grouper.held_count() == 1
def test_second_same_group_key_replaces_first(self):
next_handler = Mock()
grouper = Grouper(next_handler)
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
ev1 = make_event("e1", "priority", group_key="fire:42")
ev2 = make_event("e2", "priority", group_key="fire:42")
grouper.handle(ev1)
grouper.handle(ev2)
next_handler.assert_not_called()
assert grouper.held_count() == 1
grouper.flush_all()
assert next_handler.call_count == 1
emitted_event = next_handler.call_args[0][0]
assert emitted_event.id == "e2"
def test_tick_emits_when_window_expired(self):
next_handler = Mock()
grouper = Grouper(next_handler, window_seconds=5)
current_time = [0.0]
grouper._now = lambda: current_time[0]
current_time[0] = 0.0
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
event = make_event("e1", "priority", group_key="g")
grouper.handle(event)
assert grouper.held_count() == 1
current_time[0] = 3.0
grouper.tick()
next_handler.assert_not_called()
assert grouper.held_count() == 1
current_time[0] = 10.0
grouper.tick()
next_handler.assert_called_once()
assert grouper.held_count() == 0
def test_flush_all_emits_everything_immediately(self):
next_handler = Mock()
grouper = Grouper(next_handler)
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
ev1 = make_event("e1", "priority", group_key="g1")
ev2 = make_event("e2", "priority", group_key="g2")
ev3 = make_event("e3", "priority", group_key="g3")
grouper.handle(ev1)
grouper.handle(ev2)
grouper.handle(ev3)
assert grouper.held_count() == 3
grouper.flush_all()
assert next_handler.call_count == 3
assert grouper.held_count() == 0
# ===================== INTEGRATION TEST =====================
class TestInhibitorGrouperChain:
def test_inhibitor_then_grouper_chain(self):
terminal = Mock()
grouper = Grouper(next_handler=terminal)
inhibitor = Inhibitor(next_handler=grouper.handle)
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
# Send priority event with group_key and inhibit_keys (immediate would bypass)
ev1 = make_event("e1", "priority", group_key="g1", inhibit_keys=["k1"])
inhibitor.handle(ev1)
# After inhibitor: passed (no prior key)
# After grouper: held (group_key present)
terminal.assert_not_called()
assert grouper.held_count() == 1
# Send routine event with same group_key and inhibit_keys
ev2 = make_event("e2", "routine", group_key="g1", inhibit_keys=["k1"])
inhibitor.handle(ev2)
# After inhibitor: SUPPRESSED (k1 active at higher rank)
terminal.assert_not_called()
assert grouper.held_count() == 1 # still 1, not 2
# Flush grouper
grouper.flush_all()
terminal.assert_called_once()
emitted = terminal.call_args[0][0]
fix(notifications): Phase 2.16.1 unblock pipeline -- grouper flush + rules coercion + toggle warning Phase 2.16 found the live notification pipeline never delivered any environmental event. Two independent blocking bugs, both fixed here. BUG A -- grouper held events forever (nothing drove tick()). Every adapter event sets a group_key, so all were buffered in the Grouper and never flushed (start_pipeline only started the DigestScheduler; no tick driver existed). Fixes (per Matt's decisions): - Grouper.handle(): immediate-severity events now BYPASS the window entirely (delivered straight to next_handler), no buffering latency. routine/priority still coalesce. - start_pipeline(): schedules an asyncio flush task that calls grouper.tick() every `grouper_flush_seconds` (default 5s) so coalesced events drain within the window even when poll cadence is sparse. stop_pipeline() signals + cancels it. before/after (grouper held_count): an immediate+group_key event used to sit held (count 1) forever; now held_count==0 on arrival (bypassed). A routine event is held (count 1) then drained to 0 by tick()/flush. BUG B -- notification rules loaded as dicts, crashing the dispatcher. Root cause (more precise than 2.16's guess): the rules coercion is NOT missing from the multi-file loader -- it lives in _dict_to_dataclass's explicit `elif key == "notifications"` branch, but that branch was DEAD CODE, shadowed by the generic `if hasattr(field_type, "__dataclass_fields__")` handler that runs first for every dataclass field (including notifications). So Config.notifications.rules stayed a list of dicts on ALL load paths, and Dispatcher._matching_rules threw `AttributeError: 'dict' object has no attribute 'enabled'`. Fix: hoist the notifications special-handling ahead of the generic handler (and drop the now-truly-dead duplicate elif). before/after (cfg.notifications.rules[0] type): dict -> NotificationRuleConfig. OBS C -- empty enabled_toggles. Left as 'pass all' for v0.3 (per Matt); added a startup WARNING in build_pipeline so operators see gating is off: "enabled_toggles is empty -- ToggleFilter passing all events. Configure toggles to enable gating." (confirmed firing live). Tests: - tests/test_pipeline_grouper.py (new): test_immediate_severity_bypasses_grouper, test_periodic_flush_drains_routine, test_priority_is_also_coalesced_not_bypassed. - tests/test_config_loader.py (new): test_multifile_load_coerces_notification_rules, test_rules_attribute_access_does_not_raise (regression guards for Bug B). - tests/test_pipeline_inhibitor_grouper.py (updated): 5 existing grouper hold/coalesce/flush tests primed the grouper with immediate+group_key events expecting them to be held; switched those to 'priority' (still buffered; still outranks the routine event in the inhibitor-chain test) to match the intended immediate-bypass behavior. Full suite: 253 passed (was 248 + 5 new; 5 existing updated, none lost). VERIFICATION (rebuilt prod, traced end-to-end via in-process build_pipeline probe with a recording channel + live config): - rules[0] type: NotificationRuleConfig (Bug B fixed). - IMMEDIATE event: held_count==0 on emit (bypassed) -> reached channel.deliver(): delivered=[('PROBE_RULE','E2E IMMEDIATE')]. - ROUTINE event: held_count==1 -> after flush 0 -> reached channel.deliver(): delivered+=[('PROBE_RULE','E2E ROUTINE')]. - Natural Summit-Creek-shaped nifc wildfire_incident (routine, no matching dispatch rule): held 1 -> after flush -> landed in the digest accumulator (1 event). End-to-end channel.deliver evidence = the RecChannel.deliver() calls above. - Live container: 8 adapters, healthy, "Grouper flush task started (every 5s)", the enabled_toggles warning fired, and NO dispatcher AttributeError/traceback. Follow-up (non-blocking): several Phase 2.7-2.14 categories (e.g. wildfire_incident, earthquake_event) aren't in the category->toggle map, so they fall to toggle 'other'. Harmless while enabled_toggles is empty (pass-all), but should be mapped before toggle gating is turned on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:36:13 +00:00
assert emitted.id == "e1" # the priority event, not the suppressed routine