Skip to content

Commit a824f73

Browse files
swissmoclaude
andcommitted
fix(bulk): detect nested group conflicts and skip the states fetch for single-entity batches
Two nitpicks against the group-safety check, both verified against current code: 1. _reject_operations_group_member_conflicts always fetched states before checking whether the batch even had 2+ distinct entities, even though a single-entity batch can never contain a group/member conflict. Moved the entity_ids computation and the size check ahead of the fetch, so the overwhelming majority of operations-mode calls -- which never touch a group at all -- no longer pay for a states round-trip they can't use. 2. _find_group_member_conflicts only checked each targeted entity's DIRECT members, missing a conflict reachable through a nested aggregate (an outer Zone group containing an inner Room group, with the batch targeting the outer group and a leaf that only belongs to the inner one) -- exactly as unsafe as targeting the inner group and that leaf directly, since the outer group's own service call still cascades through the inner one. Added a small cycle-safe transitive-membership walk (_expand_membership_transitively) scoped to this check, rather than reusing bulk_selector's _expand_entity, which raises on a dangling member or cycle -- the wrong contract for a best-effort batch scan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 17b49c0 commit a824f73

2 files changed

Lines changed: 200 additions & 20 deletions

File tree

src/ha_mcp/tools/tools_service.py

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,49 @@ def _parse_bulk_operations(operations: Any) -> list[Any]:
183183
return operations_list
184184

185185

186+
def _expand_membership_transitively(
187+
entity_id: str, states_by_id: dict[str, Any], visited: set[str]
188+
) -> set[str]:
189+
"""Return every entity reachable from ``entity_id`` via nested
190+
group/aggregate membership -- direct members and, recursively, THEIR
191+
members too (an outer Zone containing an inner Room group, say).
192+
193+
``visited`` guards against a membership cycle; an entity already seen
194+
on this walk is treated as having no further members instead of
195+
recursing forever. Best-effort, not authoritative: an unknown or
196+
stateless entity simply contributes no members, mirroring
197+
``_find_group_member_conflicts``'s own tolerance for a batch that
198+
references something ``states`` doesn't have -- this is a safety-net
199+
scan over the small set of entities in one batch, not the selector
200+
resolver's own graph-integrity validation (``bulk_selector._expand_entity``),
201+
which is right to raise on exactly those cases when it is actually
202+
resolving a dispatch set.
203+
"""
204+
if entity_id in visited:
205+
return set()
206+
visited.add(entity_id)
207+
state = states_by_id.get(entity_id)
208+
if state is None:
209+
return set()
210+
members = normalize_member_entity_ids(state.get("attributes"))
211+
if not members:
212+
return set()
213+
expanded = set(members)
214+
for member_id in members:
215+
expanded.update(
216+
_expand_membership_transitively(member_id, states_by_id, visited)
217+
)
218+
return expanded
219+
220+
186221
def _find_group_member_conflicts(
187-
operations: list[Any], states: list[Any]
222+
entity_ids: set[str], states: list[Any]
188223
) -> dict[str, list[str]]:
189224
"""Return ``{group_entity_id: [conflicting_member_ids]}`` for every
190225
group/aggregate entity this batch targets alongside one or more of its
191-
own members.
226+
own members -- direct or nested (a batch targeting an outer group and a
227+
leaf reachable only through an inner group it contains is exactly as
228+
unsafe as targeting the inner group and that leaf directly).
192229
193230
Deliberately does not distinguish same-action duplicates from opposing
194231
ones (e.g. group "on" plus an explicit member "off"): Home Assistant
@@ -197,27 +234,19 @@ def _find_group_member_conflicts(
197234
against that fan-out, not a safe override, and gets flagged exactly
198235
like a same-action one.
199236
"""
200-
entity_ids = {
201-
op["entity_id"]
202-
for op in operations
203-
if isinstance(op, dict) and isinstance(op.get("entity_id"), str)
204-
}
205-
if len(entity_ids) < 2:
206-
return {}
207237
states_by_id = {
208238
state["entity_id"]: state
209239
for state in states
210240
if isinstance(state, dict) and isinstance(state.get("entity_id"), str)
211241
}
212242
conflicts: dict[str, list[str]] = {}
213243
for entity_id in sorted(entity_ids):
214-
state = states_by_id.get(entity_id)
215-
if state is None:
216-
continue
217-
members = normalize_member_entity_ids(state.get("attributes"))
218-
if not members:
244+
transitive_members = _expand_membership_transitively(
245+
entity_id, states_by_id, set()
246+
)
247+
if not transitive_members:
219248
continue
220-
overlap = sorted((entity_ids & set(members)) - {entity_id})
249+
overlap = sorted((entity_ids & transitive_members) - {entity_id})
221250
if overlap:
222251
conflicts[entity_id] = overlap
223252
return conflicts
@@ -242,7 +271,18 @@ async def _reject_operations_group_member_conflicts(
242271
Fails closed on its own states-fetch failure too: an unverifiable batch
243272
is not a verified-safe one, and this is the same infrastructure-failure
244273
stance ``bulk_selector.py`` takes for the analogous selector-mode read.
274+
A single-entity (or empty/all-malformed) batch cannot contain a
275+
group/member conflict by construction, so the states fetch is skipped
276+
entirely for it -- the overwhelming majority of operations-mode calls
277+
never touch a group at all and should not pay for this check.
245278
"""
279+
entity_ids = {
280+
op["entity_id"]
281+
for op in operations
282+
if isinstance(op, dict) and isinstance(op.get("entity_id"), str)
283+
}
284+
if len(entity_ids) < 2:
285+
return
246286
try:
247287
states = await client.get_states()
248288
except ToolError:
@@ -264,7 +304,7 @@ async def _reject_operations_group_member_conflicts(
264304
],
265305
)
266306
)
267-
conflicts = _find_group_member_conflicts(operations, states)
307+
conflicts = _find_group_member_conflicts(entity_ids, states)
268308
if not conflicts:
269309
return
270310
detail = "; ".join(

tests/src/unit/test_ha_bulk_control_selector.py

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,29 @@ async def test_existing_operations_call_shape_remains_supported() -> None:
242242
)
243243

244244

245+
@pytest.mark.asyncio
246+
async def test_operations_mode_single_entity_skips_states_fetch() -> None:
247+
"""A single distinct entity_id can never contain a group/member
248+
conflict -- there's no second row to overlap with -- so the
249+
group-safety check must not fetch states for it at all. The
250+
overwhelming majority of operations-mode calls target one entity (or
251+
several rows for the SAME entity) and never touch a group, and
252+
shouldn't pay for a states round-trip this check can never use.
253+
"""
254+
device_tools = MagicMock()
255+
device_tools.bulk_device_control = AsyncMock(return_value={"success": True})
256+
client = MagicMock()
257+
client.get_states = AsyncMock(return_value=[])
258+
tools = ServiceTools(client, device_tools)
259+
260+
result = await tools.ha_bulk_control(
261+
[{"entity_id": "light.one", "action": "off"}], False
262+
)
263+
264+
assert result == {"success": True}
265+
client.get_states.assert_not_awaited()
266+
267+
245268
def _light_state(entity_id: str, members: list[str] | None = None) -> dict:
246269
"""A minimal HA state dict; ``members`` sets the group-membership
247270
attribute Home Assistant (and ha_mcp.utils.entity_membership) reads as
@@ -396,14 +419,119 @@ async def test_operations_mode_rejects_opposing_action_group_member_conflict() -
396419
device_tools.bulk_device_control.assert_not_awaited()
397420

398421

422+
@pytest.mark.asyncio
423+
async def test_operations_mode_rejects_nested_group_conflict() -> None:
424+
"""A conflict must be caught through a NESTED aggregate, not just a
425+
direct member -- an outer Zone group containing an inner Room group,
426+
with the batch targeting the outer group and a leaf that only belongs
427+
to the inner one. Checking only ``light.outer``'s own direct
428+
``entity_id`` attribute (``["light.inner"]``) would miss this: the
429+
outer group's own service call still cascades through the inner group
430+
down to the leaf, exactly as unsafe as targeting the inner group and
431+
that leaf directly.
432+
"""
433+
device_tools = MagicMock()
434+
device_tools.bulk_device_control = AsyncMock(return_value={"success": True})
435+
client = MagicMock()
436+
client.get_states = AsyncMock(
437+
return_value=[
438+
_light_state("light.outer", ["light.inner"]),
439+
_light_state("light.inner", ["light.leaf_a", "light.leaf_b"]),
440+
_light_state("light.leaf_a"),
441+
_light_state("light.leaf_b"),
442+
]
443+
)
444+
tools = ServiceTools(client, device_tools)
445+
operations = [
446+
{"entity_id": "light.outer", "action": "off"},
447+
{"entity_id": "light.leaf_a", "action": "off"},
448+
]
449+
450+
with pytest.raises(ToolError, match="group/aggregate entity"):
451+
await tools.ha_bulk_control(operations, False)
452+
453+
device_tools.bulk_device_control.assert_not_awaited()
454+
455+
456+
@pytest.mark.asyncio
457+
async def test_operations_mode_allows_unrelated_nested_groups() -> None:
458+
"""Two independent group hierarchies with no shared membership must
459+
not cross-contaminate -- the transitive walk for one group must not
460+
leak into flagging an entity that only belongs to a completely
461+
different group's tree.
462+
"""
463+
device_tools = MagicMock()
464+
device_tools.bulk_device_control = AsyncMock(return_value={"success": True})
465+
client = MagicMock()
466+
client.get_states = AsyncMock(
467+
return_value=[
468+
_light_state("light.outer_a", ["light.inner_a"]),
469+
_light_state("light.inner_a", ["light.leaf_a"]),
470+
_light_state("light.leaf_a"),
471+
_light_state("light.outer_b", ["light.inner_b"]),
472+
_light_state("light.inner_b", ["light.leaf_b"]),
473+
_light_state("light.leaf_b"),
474+
]
475+
)
476+
tools = ServiceTools(client, device_tools)
477+
operations = [
478+
{"entity_id": "light.outer_a", "action": "off"},
479+
{"entity_id": "light.leaf_b", "action": "off"},
480+
]
481+
482+
result = await tools.ha_bulk_control(operations, False)
483+
484+
assert result == {"success": True}
485+
device_tools.bulk_device_control.assert_awaited_once_with(
486+
operations=operations, parallel=False, ctx=None
487+
)
488+
489+
490+
@pytest.mark.asyncio
491+
async def test_operations_mode_cyclic_membership_does_not_hang() -> None:
492+
"""A membership cycle (A lists B as a member, B lists A back) must not
493+
hang or crash the group-safety check -- unlike ``bulk_selector``'s own
494+
selector-mode expansion (which is resolving an authoritative dispatch
495+
set and is right to raise on a cycle), this is a best-effort safety
496+
scan over one batch and should degrade gracefully instead.
497+
"""
498+
device_tools = MagicMock()
499+
device_tools.bulk_device_control = AsyncMock(return_value={"success": True})
500+
client = MagicMock()
501+
client.get_states = AsyncMock(
502+
return_value=[
503+
_light_state("light.a", ["light.b"]),
504+
_light_state("light.b", ["light.a"]),
505+
_light_state("light.c"),
506+
]
507+
)
508+
tools = ServiceTools(client, device_tools)
509+
operations = [
510+
{"entity_id": "light.a", "action": "off"},
511+
{"entity_id": "light.c", "action": "off"},
512+
]
513+
514+
result = await tools.ha_bulk_control(operations, False)
515+
516+
assert result == {"success": True}
517+
device_tools.bulk_device_control.assert_awaited_once_with(
518+
operations=operations, parallel=False, ctx=None
519+
)
520+
521+
399522
@pytest.mark.asyncio
400523
async def test_operations_mode_group_safety_check_fails_closed_on_states_error() -> (
401524
None
402525
):
403526
"""A states-fetch failure during the group-safety check must not
404527
silently skip the check and dispatch anyway: an unverifiable batch is
405528
not a verified-safe one, matching the fail-closed stance
406-
bulk_selector.py takes for the analogous selector-mode read."""
529+
bulk_selector.py takes for the analogous selector-mode read.
530+
531+
Two distinct entities: a single-entity batch short-circuits before the
532+
states fetch (see test_operations_mode_single_entity_skips_states_fetch),
533+
so it would never reach this code path.
534+
"""
407535
device_tools = MagicMock()
408536
device_tools.bulk_device_control = AsyncMock(return_value={"success": True})
409537
client = MagicMock()
@@ -412,7 +540,11 @@ async def test_operations_mode_group_safety_check_fails_closed_on_states_error()
412540

413541
with pytest.raises(ToolError):
414542
await tools.ha_bulk_control(
415-
[{"entity_id": "light.one", "action": "off"}], False
543+
[
544+
{"entity_id": "light.one", "action": "off"},
545+
{"entity_id": "light.two", "action": "off"},
546+
],
547+
False,
416548
)
417549

418550
device_tools.bulk_device_control.assert_not_awaited()
@@ -433,7 +565,11 @@ async def test_operations_mode_group_safety_check_fails_closed_on_malformed_stat
433565

434566
with pytest.raises(ToolError, match="Home Assistant"):
435567
await tools.ha_bulk_control(
436-
[{"entity_id": "light.one", "action": "off"}], False
568+
[
569+
{"entity_id": "light.one", "action": "off"},
570+
{"entity_id": "light.two", "action": "off"},
571+
],
572+
False,
437573
)
438574

439575
device_tools.bulk_device_control.assert_not_awaited()
@@ -463,7 +599,11 @@ async def test_operations_mode_group_safety_check_propagates_tool_error_unchange
463599

464600
with pytest.raises(ToolError) as exc_info:
465601
await tools.ha_bulk_control(
466-
[{"entity_id": "light.one", "action": "off"}], False
602+
[
603+
{"entity_id": "light.one", "action": "off"},
604+
{"entity_id": "light.two", "action": "off"},
605+
],
606+
False,
467607
)
468608

469609
assert str(exc_info.value) == str(original)

0 commit comments

Comments
 (0)