Skip to content

Commit bb13eb2

Browse files
fix: report per-item failures in bulk operation status instead of aborting the batch (#2076)
Started as the residual-dead-code sweep from #2043's final review; reviewing that sweep surfaced real defects in the operation-status path, fixed here as well. Fixes (user-facing): * ha_get_operation_status with a list of IDs re-raised the first failed/timed-out/not-found operation's error, discarding the status of every other operation and making the failed/timeout aggregation unreachable. Per-item errors now become structured entries in detailed_results — preserving the full structured payload including top-level context keys — with a not_found bucket in the summary and a top-level success key per the repo-wide response contract. * The bulk path polled each pending operation serially for the full 10s single-op timeout while the docstring promised "a short internal timeout"; it now polls all operations concurrently under the caller's timeout_seconds window. * cleanup_expired_operations never reclaimed TIMEOUT-status operations (get_operation() flips an expired PENDING op to TIMEOUT in place on the read path), so polled-after-timeout operations accumulated for the process lifetime. TIMEOUT now shares FAILED's 60s TTL, anchored on completion_time, and the sweep-marked path keeps the same terminal minute as the read path so a poll after expiry reports timeout rather than not_found regardless of which path noticed first. The overflow trim considers all terminal statuses — never in-flight PENDING — so max_operations is enforceable except against a backlog of purely in-flight PENDING operations, which expire on their own timeouts. * completion_percentage is now the terminal fraction (completed + failed + not_found over total) so it agrees with all_complete; success_rate keeps the successful fraction. The ha_get_operation_status docstring starts with an approved verb (Get) with the locale source baseline regenerated. * get_domain_handler required a dot in its argument while both control paths pass a bare domain, so every per-domain valid_actions table was unreachable and the default handler's on/off/toggle applied everywhere — climate's heat/cool/set were rejected upfront as invalid actions (closes #2090). The lookup now accepts an entity ID or a bare domain, the tables were made purely additive and resolvable (short on/off/toggle actions restored where their services exist; unmappable entries mapped — cover stop/set, media next/previous, lock open, alarm arm_*/disarm, climate heat_cool — or trimmed), and a new e2e pin drives every table action through the resolver against the live service list. Dead-code residuals from #2043 (internal): * settings_ui/locales/es.json: drop the orphaned advanced.entity_search_limit.* keys (es.json merged via #2055 mid-review; the help string still named ha_search_entities). * tools/util_helpers.py: remove strip_internal_fields (its only production caller was the server bridge deleted in #2043); its rationale moves into public_fields' docstring. * client/websocket_listener.py: drop the unread self.settings assignment and its import. * tests/src/unit/test_websocket_listener.py: fixture no longer builds the stats dict deleted in #2043. * utils/operation_manager.py: remove OperationStatus.CANCELLED (unproducible since cancel_operation went; nothing ever read it). Tests: new unit pins for the cleanup TTLs (terminal anchor, discriminating overflow trim), the bulk summary contract (completion_percentage semantics, preserved per-item payload), and the shared wait window (a pending operation flipping to completed inside the window, red under a 0s snapshot); real per-item assertions in the bulk-status e2e test via MCPAssertions; the mixed-entities e2e block in test_network_errors made live end to end (the bulk response has no top-level success key, so the gate now mirrors assert_mcp_success's bulk indicator; the action string is "on" — "turn_on" was rejected for every entity; value checks pin that the valid entities succeed and both fabricated entities fail as per-item entries; a 5s status window avoids dead-heating the 10s call wrapper); projection pin for the area-only branch's public_fields strip; flip task joined via asyncio.gather per CodeQL.
1 parent 83168a4 commit bb13eb2

16 files changed

Lines changed: 817 additions & 239 deletions

src/ha_mcp/client/websocket_listener.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import logging
1010
from typing import Any
1111

12-
from ..config import get_global_settings
1312
from ..utils.operation_manager import get_operation_manager, update_pending_operations
1413
from .websocket_client import HomeAssistantWebSocketClient, get_websocket_client
1514

@@ -21,7 +20,6 @@ class WebSocketListenerService:
2120

2221
def __init__(self) -> None:
2322
"""Initialize the WebSocket listener service."""
24-
self.settings = get_global_settings()
2523
self.operation_manager = get_operation_manager()
2624
self.websocket_client: HomeAssistantWebSocketClient | None = None
2725
self.listener_task: asyncio.Task | None = None

src/ha_mcp/tools/device_control.py

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -301,33 +301,50 @@ def _resolve_service_name(
301301
action: str,
302302
parameters: dict[str, Any] | None,
303303
) -> tuple[str, dict[str, Any] | None]:
304-
service_mapping = {
305-
"on": "turn_on",
306-
"off": "turn_off",
307-
"toggle": "toggle",
308-
"open": "open_cover" if domain == "cover" else "turn_on",
309-
"close": "close_cover" if domain == "cover" else "turn_off",
310-
"set": "turn_on" if domain == "light" else "set_temperature",
311-
}
312-
313-
service_name = service_mapping.get(action, action)
304+
if domain == "climate" and action in ("heat", "cool", "auto", "heat_cool"):
305+
parameters = dict(parameters or {})
306+
parameters["hvac_mode"] = action
307+
return "set_hvac_mode", parameters
314308

315-
if domain == "climate":
316-
if action in ["heat", "cool", "auto"]:
317-
service_name = "set_hvac_mode"
318-
if not parameters:
319-
parameters = {}
320-
parameters["hvac_mode"] = action
321-
elif action == "set":
322-
service_name = "set_temperature"
309+
service_name = self._SERVICE_OVERRIDES.get(
310+
(domain, action), self._GENERIC_SERVICE_MAP.get(action, action)
311+
)
312+
return service_name, parameters
323313

324-
elif domain == "media_player":
325-
if action in ["play", "pause", "stop"]:
326-
service_name = f"media_{action}"
327-
elif action == "set":
328-
service_name = "volume_set"
314+
# Domain-specific action-to-service mappings; anything not listed
315+
# falls through to _GENERIC_SERVICE_MAP, then to the action verbatim.
316+
# tests/src/e2e/workflows/device_control/test_action_service_resolution.py
317+
# pins every DOMAIN_HANDLERS table entry through this resolver against
318+
# a live instance's service list.
319+
_SERVICE_OVERRIDES: ClassVar[dict[tuple[str, str], str]] = {
320+
("cover", "open"): "open_cover",
321+
("cover", "close"): "close_cover",
322+
("cover", "stop"): "stop_cover",
323+
("cover", "set"): "set_cover_position",
324+
("light", "set"): "turn_on",
325+
# lock.open exists; the generic mapping's turn_on does not.
326+
("lock", "open"): "open",
327+
("climate", "set"): "set_temperature",
328+
("media_player", "play"): "media_play",
329+
("media_player", "pause"): "media_pause",
330+
("media_player", "stop"): "media_stop",
331+
("media_player", "next"): "media_next_track",
332+
("media_player", "previous"): "media_previous_track",
333+
("media_player", "set"): "volume_set",
334+
("alarm_control_panel", "arm_home"): "alarm_arm_home",
335+
("alarm_control_panel", "arm_away"): "alarm_arm_away",
336+
("alarm_control_panel", "arm_night"): "alarm_arm_night",
337+
("alarm_control_panel", "disarm"): "alarm_disarm",
338+
}
329339

330-
return service_name, parameters
340+
_GENERIC_SERVICE_MAP: ClassVar[dict[str, str]] = {
341+
"on": "turn_on",
342+
"off": "turn_off",
343+
"toggle": "toggle",
344+
"open": "turn_on",
345+
"close": "turn_off",
346+
"set": "set_temperature",
347+
}
331348

332349
_DOMAIN_PARAMS: ClassVar[dict[str, list[str]]] = {
333350
"light": ["brightness", "color_temp_kelvin", "rgb_color", "effect"],
@@ -1274,13 +1291,20 @@ def _build_bulk_response(
12741291
return response
12751292

12761293
async def get_bulk_operation_status(
1277-
self, operation_ids: list[str]
1294+
self, operation_ids: list[str], timeout_seconds: int = 10
12781295
) -> dict[str, Any]:
12791296
"""
12801297
Check status of multiple operations.
12811298
1299+
Polls all operations concurrently under one shared
1300+
``timeout_seconds`` window, so the wall time is bounded by the
1301+
window rather than growing with the batch size. Per-item failures
1302+
become structured entries in ``detailed_results`` rather than
1303+
aborting the batch.
1304+
12821305
Args:
12831306
operation_ids: List of operation IDs to check
1307+
timeout_seconds: Wait window applied to every operation
12841308
12851309
Returns:
12861310
Status summary for all operations
@@ -1296,26 +1320,69 @@ async def get_bulk_operation_status(
12961320
)
12971321
)
12981322

1299-
# Check all operations
1300-
statuses = []
1301-
for op_id in operation_ids:
1302-
status = await self.get_device_operation_status(op_id)
1303-
statuses.append(status)
1323+
# Check all operations concurrently under one shared wait window.
1324+
# Per-item failures must not abort the batch: get_device_operation_status
1325+
# raises ToolError for failed / timed-out / not-found operations, so each
1326+
# one is caught and folded back into detailed_results as a structured
1327+
# entry — otherwise the first bad operation would discard the status of
1328+
# every other one. The whole parsed error payload is preserved (context
1329+
# keys like entity_id / duration_ms sit at its top level), with the
1330+
# batch "status" field layered on for the summary counts.
1331+
error_code_to_status = {
1332+
ErrorCode.SERVICE_CALL_FAILED.value: "failed",
1333+
ErrorCode.TIMEOUT_OPERATION.value: "timeout",
1334+
ErrorCode.RESOURCE_NOT_FOUND.value: "not_found",
1335+
}
1336+
1337+
async def check_one(op_id: str) -> dict[str, Any]:
1338+
try:
1339+
return await self.get_device_operation_status(
1340+
op_id, timeout_seconds=timeout_seconds
1341+
)
1342+
except ToolError as e:
1343+
try:
1344+
err = json.loads(str(e))
1345+
except ValueError:
1346+
err = {"success": False, "error": {"message": str(e)}}
1347+
error_info = err.get("error") or {}
1348+
return {
1349+
**err,
1350+
"operation_id": op_id,
1351+
"status": error_code_to_status.get(
1352+
error_info.get("code", ""), "failed"
1353+
),
1354+
}
1355+
1356+
statuses = list(
1357+
await asyncio.gather(*(check_one(op_id) for op_id in operation_ids))
1358+
)
13041359

13051360
# Summarize results
13061361
completed = len([s for s in statuses if s.get("status") == "completed"])
13071362
failed = len([s for s in statuses if s.get("status") in ["failed", "timeout"]])
1363+
not_found = len([s for s in statuses if s.get("status") == "not_found"])
13081364
pending = len([s for s in statuses if s.get("status") == "pending"])
13091365

1366+
# The batch call itself succeeded; per-item failures live inside
1367+
# detailed_results (batch-item pattern). Top-level ``success`` is
1368+
# the repo-wide response contract every tool return carries.
13101369
return {
1370+
"success": True,
13111371
"total_operations": len(operation_ids),
13121372
"completed": completed,
13131373
"failed": failed,
1374+
"not_found": not_found,
13141375
"pending": pending,
13151376
"all_complete": pending == 0,
13161377
"summary": {
13171378
"success_rate": f"{completed}/{len(operation_ids)}",
1318-
"completion_percentage": (completed / len(operation_ids)) * 100,
1379+
# Terminal fraction (nothing left in flight) — consistent
1380+
# with all_complete; success_rate carries the successful
1381+
# fraction separately.
1382+
"completion_percentage": (
1383+
(completed + failed + not_found) / len(operation_ids)
1384+
)
1385+
* 100,
13191386
},
13201387
"detailed_results": statuses,
13211388
"recommendations": (
@@ -1324,7 +1391,7 @@ async def get_bulk_operation_status(
13241391
"Check failed operations for specific error messages",
13251392
"Retry failed operations with different parameters if needed",
13261393
]
1327-
if pending > 0 or failed > 0
1394+
if pending > 0 or failed > 0 or not_found > 0
13281395
else ["All operations completed successfully!"]
13291396
),
13301397
}

src/ha_mcp/tools/tools_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,13 +1385,15 @@ async def ha_get_operation_status(
13851385
timeout_seconds: int = 10,
13861386
) -> dict[str, Any]:
13871387
"""
1388-
Check status of one or more device operations with real-time WebSocket verification.
1388+
Get the status of one or more device operations with real-time WebSocket verification.
13891389
13901390
Pass a single operation_id string to check one operation, or a list of IDs
13911391
to check multiple operations at once (bulk status).
13921392
1393-
The timeout_seconds parameter applies to single-operation checks only.
1394-
Bulk checks poll each operation individually with a short internal timeout.
1393+
The timeout_seconds wait window bounds both modes. Bulk checks poll
1394+
all operations concurrently under one shared window and report
1395+
per-item failures inside detailed_results instead of aborting the
1396+
batch.
13951397
13961398
Use this to track operations initiated by ha_bulk_control or ha_call_service.
13971399
For current entity states, use ha_get_state instead.
@@ -1401,7 +1403,7 @@ async def ha_get_operation_status(
14011403
# before the body runs, so operation_id is already the final shape.
14021404
if isinstance(operation_id, list):
14031405
result = await self._device_tools.get_bulk_operation_status(
1404-
operation_ids=operation_id
1406+
operation_ids=operation_id, timeout_seconds=timeout_seconds
14051407
)
14061408
return cast(dict[str, Any], result)
14071409
result = await self._device_tools.get_device_operation_status(

src/ha_mcp/tools/util_helpers.py

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -77,43 +77,14 @@ def summarize_theme_listing(raw_themes: dict[str, Any]) -> dict[str, Any]:
7777
}
7878

7979

80-
def strip_internal_fields(obj: Any, _seen: set[int] | None = None) -> Any:
81-
"""Remove leading-underscore keys from ``obj`` and any nested dicts
82-
or lists in place.
80+
def public_fields(d: dict[str, Any]) -> dict[str, Any]:
81+
"""Return a shallow copy of ``d`` with leading-underscore keys removed.
8382
8483
The ha-mcp tool layer enriches entity / area dicts with internal
8584
fields like ``_hidden_by`` and ``_aliases`` so downstream branches
8685
can rank without re-querying the entity registry. Those keys must
8786
not leak through public tool returns: this helper centralises the
8887
convention so individual call sites don't have to remember to strip.
89-
90-
Mutates in place and returns the same reference for chaining. Cycle
91-
guard via ``_seen`` (id-tracked) keeps the recursion safe if a
92-
future caller ever feeds it a non-tree structure — JSON payloads
93-
don't, but the helper is now a generic utility (importable from
94-
``server.py``) so the protection is cheap insurance.
95-
"""
96-
if _seen is None:
97-
_seen = set()
98-
obj_id = id(obj)
99-
if obj_id in _seen:
100-
return obj
101-
if isinstance(obj, dict):
102-
_seen.add(obj_id)
103-
for key in [k for k in obj if isinstance(k, str) and k.startswith("_")]:
104-
obj.pop(key, None)
105-
for value in obj.values():
106-
strip_internal_fields(value, _seen)
107-
elif isinstance(obj, list):
108-
_seen.add(obj_id)
109-
for item in obj:
110-
strip_internal_fields(item, _seen)
111-
return obj
112-
113-
114-
def public_fields(d: dict[str, Any]) -> dict[str, Any]:
115-
"""Return a shallow copy of ``d`` with leading-underscore keys
116-
removed. Non-mutating counterpart to :func:`strip_internal_fields`.
11788
Shallow only — list/dict values are shared with the source, so a
11889
later mutation of those values would propagate.
11990
"""

0 commit comments

Comments
 (0)