fix: report per-item failures in bulk operation status instead of aborting the batch - #2076
fix: report per-item failures in bulk operation status instead of aborting the batch#2076kingpanther13 wants to merge 17 commits into
Conversation
The #2043 cleanup orphaned a few things its own deletions exposed, and the es locale landed on master mid-review carrying the setting the PR removed: - es.json: drop the orphaned entity_search_limit label/help keys (the setting was removed in #2043; the help still named ha_search_entities) - util_helpers: remove strip_internal_fields — its only production caller was the get_entities_by_area server bridge deleted in #2043; fold its rationale into public_fields' docstring and reduce the test module to the public_fields contract - websocket_listener: drop the unread self.settings assignment and its now-unused get_global_settings import - test_websocket_listener: stop grafting the stats dict removed in #2043 onto the listener fixture - operation_manager: remove OperationStatus.CANCELLED — unproducible since cancel_operation went, leaving only a dead predicate arm
|
@codex review — apply the review criteria in .gemini/styleguide.md in addition to AGENTS.md guidance |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…rting the batch Review findings on this PR, all in the operation-status path: - get_bulk_operation_status re-raised the first failed/timed-out/ not-found operation's ToolError, discarding the status of every other operation in the batch and making the failed/timeout aggregation unreachable. Per-item errors are now folded into detailed_results as structured entries (batch-item pattern), with a not_found bucket in the summary. - The bulk path also polled each pending operation serially for the full 10s single-op timeout while the tool docstring promised a short internal timeout; it now takes an immediate per-operation snapshot. - cleanup_expired_operations never reclaimed TIMEOUT-status operations: get_operation() flips an expired PENDING op to TIMEOUT in place on the read path, outside the mark-and-remove pass, so polled-after- timeout operations accumulated for the process lifetime. TIMEOUT now shares FAILED's 60s TTL, and the overflow trim considers all terminal statuses (never in-flight PENDING) so max_operations is enforceable. - New unit pins for the cleanup TTLs and overflow trim (previously zero coverage), a projection pin for the area-only branch's public_fields strip, and real per-item assertions in the bulk-status e2e test. - Stale count in the locale-parity ceiling comment (419 -> 417 after #2043) and a corrected failure-mode description in the strip-helper test docstring.
The bulk summary dict never had a top-level "success" key — invisible until now because the batch always aborted with a ToolError before returning (the bug the previous commit fixed). The two dispatch e2e tests that assert the repo-wide success/error contract caught it on the first non-aborting run. Also pins success=True in the bulk e2e test.
|
@codex review — apply the review criteria in .gemini/styleguide.md in addition to AGENTS.md guidance |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2f26fb080
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- restore a bounded wait for bulk verification: all operations now poll concurrently under the caller's timeout_seconds window (previously hard-coded to a 0s snapshot, which guaranteed pending on the immediate-verify path and ignored the public timeout_seconds arg for list inputs) - preserve the complete structured error payload in batch entries (context keys like entity_id/duration_ms sit at its top level and were being discarded) - anchor the terminal TTLs in cleanup_expired_operations on completion_time so a long-timeout op flipped to TIMEOUT by the read path keeps its full terminal minute instead of being reclaimed instantly from an old start_time - compute completion_percentage from all terminal results so it cannot contradict all_complete when not_found is nonzero - route the bulk e2e success assertion through MCPAssertions - start the ha_get_operation_status docstring with an approved verb (Check -> Get) and regenerate the locale source baseline for it
Patch76
left a comment
There was a problem hiding this comment.
Reviewed at 480ad3d. I reverted each of the behavioural changes in isolation and re-ran the new unit file to see which ones the tests actually catch. The TTL changes go red; the overflow-trim widening does not.
Concern 1: test_overflow_trims_oldest_terminal_never_pending does not pin the widening. Reverting operation_manager.py:324 from op.status != OperationStatus.PENDING back to op.status == OperationStatus.COMPLETED leaves all 10 tests in the new file green. The fixture holds exactly one COMPLETED op and excess == 1, so both variants evict the same entry. A fixture with no COMPLETED op discriminates, and it is also the case the change exists for — the old trim could not enforce the cap at all when nothing was COMPLETED:
def test_overflow_trims_terminal_when_nothing_is_completed(self):
manager = _manager_with(
_make_operation("failed-older", OperationStatus.FAILED, 50),
_make_operation("timeout-newer", OperationStatus.TIMEOUT, 20),
_make_operation("pending-live", OperationStatus.PENDING, 1, timeout_ms=60000),
)
manager.operations["failed-older"].completion_time = (time.time() - 50) * 1000
manager.operations["timeout-newer"].completion_time = (time.time() - 20) * 1000
manager.max_operations = 2
manager.cleanup_expired_operations(force=True)
assert len(manager.operations) == 2
assert "failed-older" not in manager.operations
assert "timeout-newer" in manager.operations
assert "pending-live" in manager.operationsAppended, the file is 11 passed at 480ad3d, and under the revert above the new test is the only one of the 11 that fails. The other changes already discriminate: dropping TIMEOUT from the TTL predicate reddens test_read_path_timeout_operation_is_reclaimed and test_old_timeout_operation_is_removed, and dropping the completion_time anchor reddens the read-path test and test_terminal_ttl_anchors_on_completion_time.
Concern 2: the PR body still describes the pre-480ad3df design. Its second fix bullet says the bulk path "now takes an immediate per-operation snapshot", which 480ad3d replaced with a concurrent poll under the caller's timeout_seconds — as that commit message itself records. Worth correcting before the squash.
Concern 3: completion_percentage changed meaning and nothing pins it. The identifier occurs exactly once in the repository, at its own definition in device_control.py:1365: no test asserts it, no docstring documents it, and the only readers are agents consuming the response. Terminal fraction is the right call next to all_complete, but one assertion in a bulk-status unit test would stop it drifting back to the success fraction.
Two smaller points. The body's "so max_operations is enforceable" overstates the result, since a backlog of purely PENDING operations still exceeds the cap by design. And the cleanup pass's own expired-PENDING branch at operation_manager.py:308-312 marks TIMEOUT and removes in the same sweep, so an operation the sweep reaches first never gets the terminal minute the read-path one now keeps, and a poll after that reports not_found instead of timeout. That is pre-existing and pinned by test_expired_pending_is_marked_timeout_and_removed, so it looks deliberate, but the new anchoring rationale invites the question.
The rest of the sweep is clean: strip_internal_fields survives only in one test name and OperationStatus.CANCELLED is gone entirely, self.settings and stats no longer appear in the listener at all, and the es catalog is back at en's 417 message keys, with de and fr topping the English-identical count at 9 (2.2%) exactly as the updated ceiling comment states.
…k summary contract Review follow-ups (Patch76): - the cleanup sweep's expired-PENDING branch marked TIMEOUT and removed in the same pass, so an operation the sweep reached first never got the terminal minute a read-path timeout now keeps — a poll shortly after expiry reported not_found instead of timeout depending on which path noticed first. The sweep now marks and keeps; the TTL branch reclaims a minute later. Uniform behaviour either way. - add the discriminating overflow-trim test: with no COMPLETED op present the old COMPLETED-only trim could not enforce the cap at all, so the widened trim's test must run without one (the previous fixture passed under both variants). - pin the bulk summary contract in a unit test: completion_percentage is the terminal fraction, success_rate the successful fraction, per-item failure entries preserve the structured payload including top-level context keys.
|
All three addressed at b0a8c08. Your discriminating overflow test is in verbatim — confirmed the original fixture passed under both trim variants. completion_percentage and the batch-entry payload are pinned in a new bulk-summary unit test. On the sweep-path asymmetry: it wasn't deliberate — the sweep now marks and keeps expired PENDING ops for the same terminal minute as the read path, so the not_found-vs-timeout outcome no longer depends on which path noticed first. PR body corrected for the snapshot wording and the max_operations overstatement. |
Patch76
left a comment
There was a problem hiding this comment.
Re-reviewed the delta from 480ad3d to b0a8c08. The three concerns and both smaller points from the last round are addressed, including the PR body, which now describes the concurrent wait window and no longer overstates the max_operations guarantee. I checked the new pins by reverting each behavioural change and re-running rather than by reading the asserts; each revert reddened exactly one test.
- Reinstating
to_remove.append(op_id)in the sweep's expired-PENDING branch (operation_manager.py:308-315) reddenstest_operation_manager_cleanup.py::test_expired_pending_is_marked_timeout_and_kept_for_terminal_minute. - Narrowing the overflow trim at
operation_manager.py:327back toop.status == OperationStatus.COMPLETEDreddenstest_operation_manager_cleanup.py::test_overflow_trims_terminal_when_nothing_is_completed. - Reverting
completion_percentageatdevice_control.py:1365-1366to the successful fraction reddenstest_bulk_operation_status_summary.py::test_all_terminal_batch_reports_full_completion.
The shared wait window on the bulk path is the one behavioural fix here that nothing pins. Hardcoding timeout_seconds=0 inside check_one (device_control.py:1320-1324) — the state the earlier round flagged as guaranteeing pending on the immediate-verify path — is invisible to the test tree: the three tests in test_bulk_operation_status_summary.py are the only callers of get_bulk_operation_status anywhere under tests/, and each passes timeout_seconds=0 itself (lines 56, 89, 102). A unit run under that mutation came back identical here, but the count is not the evidence, the caller set is.
The e2e side does not cover it either. The bulk cases in test_operation_status.py pass fabricated IDs, which device_control.py:460-472 rejects with RESOURCE_NOT_FOUND before the poll block at :478, or an empty list, which is rejected earlier still. The only e2e call reaching the bulk path with real operation IDs, test_network_errors.py:365-379, reads a statuses key the bulk summary does not return and asserts nothing on the result — it logs a count that is therefore always zero.
test_get_operation_status_wait.py::test_polls_until_completion_within_timeout already covers the pending-to-completed transition on the single-operation path. The bulk analogue — a batch holding one pending operation that flips inside the window, asserted to come back completed — would close the gap, and would fail under a 0s snapshot.
The rest of the delta reads clean: the terminal TTL anchors on completion_time for both the read path and the sweep, the trim still never touches in-flight PENDING, and the asymmetry the sweep change leaves behind is invisible to callers, because the timeout branch at device_control.py:543-547 builds its message from timeout_ms and never reads operation.error_message — only the FAILED branch at :523-527 does.
Review follow-ups (Patch76, round 2): every behavioural fix now has a discriminating pin except the shared wait window, whose only callers in the test tree passed timeout_seconds=0 themselves — added a unit test where a pending operation flips to completed inside the window (red under a 0s snapshot). Also replaced test_network_errors' read of a nonexistent "statuses" key — which made its failed-ops count unconditionally zero — with real assertions over detailed_results.
CodeQL quality flagged the bare 'await flip' as a no-effect statement; gathering the bulk call and the flip coroutine together reads cleaner and uses both results.
|
Both closed at 9d66161. The wait window is pinned by a unit test where a pending operation flips to completed inside the window — red under a 0s snapshot. And you were right about test_network_errors: its |
Patch76
left a comment
There was a problem hiding this comment.
The wait-window pin is closed, and I checked it by injection rather than by reading: hardcoding timeout_seconds=0 in check_one (device_control.py:1323) reddens test_bulk_wait_window_observes_completion_inside_it on assert result["completed"] == 1 and leaves the other three tests in that file green. It fails for the mutation it exists to catch and for nothing else, which is what the earlier round could not say about it.
[Concern 1]: The test_network_errors repair reads the right key, but the block it sits in never executes, so the new assertion is dead code. The detailed_results part is correct on its own terms: get_bulk_operation_status carries success: True on every return path (device_control.py:1353) and builds the list by asyncio.gather over operation_ids with no cap and no dedup (:1340, :1370), so len(detailed) == len(operation_ids) holds by construction rather than by luck. The problem is one gate further out. test_network_errors.py:354 guards everything with if mixed_bulk_data.get("success"):, and bulk_device_control's response has no top-level success key — _build_bulk_response (:1228) emits total_operations, successful_commands, operation_ids, execution_mode and follow_up, and the only three "success" literals in the module sit at :505, :1329 and :1353, none of them in that builder. parse_mcp_result returns the parsed payload untouched and synthesises success: False only on its error paths (assertions.py:48-49), and _safe_tool_call passes the raw result through unless it times out. So the gate reads None, the else at :386 logs "Mixed entities correctly failed", and the assertion added by 9d661610 is skipped along with the rest of the branch. The repo's own helper documents that shape: assert_mcp_success carries a dedicated indicator for "bulk operations success: has operational data without explicit success field" (assertions.py:131). Routing the :354 gate through it would open the branch. One thing to expect once it does open: the status call takes the default timeout_seconds=10 (tools_service.py:1254) inside a _safe_tool_call(timeout=10.0) wrapper (test_network_errors.py:37), so the two deadlines are a dead heat and a still-pending operation can trip the wrapper rather than the poll. Passing a window below the wrapper timeout removes that.
Minor: Auto-merge was armed at 19:47Z and its stored commit body covers the first five commits; 9d661610 and bb2ca241 are absent from it, and the API still returns it that way. The stored headline is right and changelog entries render from the subject alone, so releases are unaffected — but the squash body is where the wait-window pin and the gather change get explained. Worth refreshing before it merges.
Nit: import asyncio sits inside test_bulk_wait_window_observes_completion_inside_it (test_bulk_operation_status_summary.py:103) while the module's import block is at the top next to time.
All 36 non-skipped checks on the head are green, so this is only about that e2e gate.
…ssertion dead Review follow-up (Patch76, round 3): the bulk_device_control response carries no top-level success key, so test_network_errors' gate never opened and the detailed_results assertion added earlier never ran. The gate now mirrors assert_mcp_success's bulk indicator (no error + operation_ids present), and the inner status call passes a 5s window so it cannot dead-heat _safe_tool_call's 10s wrapper. Also hoists the asyncio import to the module block.
|
All three at 5d16584. The gate now mirrors assert_mcp_success's bulk indicator (no error + operation_ids present) so the detailed_results assertions actually execute, and the inner status call passes a 5s window to stay clear of the wrapper's 10s. Auto-merge was re-armed with a refreshed squash body covering all eight commits, and the asyncio import is in the module block. |
Patch76
left a comment
There was a problem hiding this comment.
The gate half is fixed and fixed correctly: _build_bulk_response returns operation_ids and carries neither an error nor a success key, so the new condition opens where get("success") never could. The asyncio import is in the module block, and the squash body was re-armed at 05:24:23Z, after the head commit.
[Concern 1]: The assertion is still dead, one level further down, and this time deterministically rather than environmentally. The call sends {"entity_id": …, "action": "turn_on"} (test_network_errors.py:346). control_device_smart splits the entity ID and hands the bare domain to get_domain_handler (device_control.py:130), which returns the default handler for any argument without a dot, so valid_actions is ["on", "off", "toggle"] and turn_on raises SERVICE_INVALID_ACTION at :139 before store_pending_operation is reached. That applies to the valid entity as much as to the two fabricated ones, so operation_ids comes back empty for the whole batch, if operation_ids: at :370 is false, and both the timeout_seconds: 5 status call and the len(detailed) == len(operation_ids) assertion are skipped exactly as they were before. Every other bulk test in the repo uses "action": "on", including the same valid-plus-nonexistent pairing at test_bulk.py:306-307. Changing that one string should make the block live.
Worth deciding once it does run: detailed_results is a gather over operation_ids, so len(detailed) == len(operation_ids) holds by construction and cannot fail for any input, and the status in ("failed", "timeout") count beside it is still only logged. If the point is to pin that invalid entities surface as per-item entries instead of aborting the batch — which is what this PR is for — asserting on the statuses inside detailed_results would carry that, and test_bulk.py:306-307 already drives the same shape.
One thing to correct before this merges rather than after: the armed squash body says the gate "opened (the bulk response has no top-level success key, so its detailed_results assertions never ran) with a 5s status window to avoid dead-heating the 10s call wrapper". While the action stays turn_on, neither half of that describes what happens, and auto-merge would commit the sentence to master's history.
Not this PR's to fix, but it is what let the wrong action string pass unnoticed: both get_domain_handler call sites (device_control.py:130 and :753) pass a bare domain, and the function falls back to the default handler whenever its argument contains no dot. The per-domain valid_actions tables are therefore unreachable from control_device_smart, which is why the error reads "Invalid action 'turn_on' for domain 'light'" and then lists "on, off, toggle" while the light entry also declares set and adjust. I can file that separately if it is useful.
Review follow-up (Patch76, round 4): both control paths pass a bare domain to get_domain_handler, whose dot-guard sent every such call to the default handler — the per-domain valid_actions tables were unreachable, so e.g. climate's heat/cool/set were rejected upfront as invalid actions while the error listed only on/off/toggle. The lookup now accepts an entity ID or a bare domain (new unit pins, including sensor's read-only empty action set). This is also what kept the mixed-entities e2e block dead one level further down: its action was "turn_on", which no handler accepts — switched to "on" like every other bulk test, and the vacuous len(detailed) == len(operation_ids) assertion (true by construction via gather) is replaced with per-entry batch-status assertions, which is the contract this PR exists to pin.
The inaddon leg failed once at run 30525619431: a sabotage docker exec hit 'No such container: addon_local_ha_mcp_dev'. Supervisor stop/start cycles (watchdog recovery, restart, update swap) remove the addon container and create a fresh one, so any docker-exec helper can race the swap. ssh_exec already centralises transient retries for SSH cold-start races; the container-swap window is the same class, so its transient predicate now covers 'No such container' / 'is not running' under the existing 60s deadline.
Temporarily reverts ONLY the bare-domain handler fix (and its unit tests) while keeping every e2e change, so the now-live mixed-entities block and the schedule shift are preserved. If the inaddon leg goes green here, the handler change is the causal ingredient; if it still fails, the cause is elsewhere. This commit will be reverted either way before merge.
… failure" This reverts commit 3ea7d54.
…r 2026.07.4 renamed addon_<slug> to app_<slug> Root cause of the 3x-deterministic inaddon failure, pinned by forensics against upstream source and the run diagnostics: DockerApp.slug_to_name returns app_<slug> since Supervisor 2026.07.4 (with legacy containers renamed on attach), the booted HAOS self-updates Supervisor at boot, and stable.json flipped from 2026.07.3 to 2026.07.5 between this branch's green run and the first red run — a wall-clock coincidence with the preceding commit, which a handler-reverted CI experiment confirmed is not causal. The dev addon was alive and serving the whole time; docker exec was querying a container name that no longer exists. docker_exec_in_addon now resolves the live container name per call (accepting both prefixes) and retries the resolve+exec pair under one deadline across Supervisor's genuine stop/start swap window, attaching the docker ps -a listing when nothing resolves. ssh_exec's transient predicate returns to transport-level tokens only — the previous commit's blanket no-such-container retry misattributed this permanent rename to a transient swap and converted a one-line misconfiguration into a 64s timeout. Note for main: every inaddon run since the stable.json flip is red on any branch until this lands.
|
All at 1fc7b19, plus a detour worth reporting. The bare-domain lookup is fixed here rather than filed: a one-function change in a file this PR already touches, and it was also the root cause of the dead block — no action string a table declares would have behaved differently with the default handler swallowing every lookup. The e2e block uses "on" like test_bulk, and the length check is replaced with per-entry batch-status assertions. The detour: the inaddon leg then went deterministically red at the sabotage docker exec — "No such container: addon_local_ha_mcp_dev". Root cause is Supervisor 2026.07.4 renaming addon containers to app_ (DockerApp.slug_to_name; legacy names migrated on attach), which the self-updating test VM picked up when stable.json flipped between our green and red runs — every inaddon run on every branch is red until this lands. docker_exec_in_addon now resolves the live container name per call (both prefixes) and retries resolve+exec across genuine swap windows; my earlier blanket no-such-container retry is corrected back to transport-only tokens, since it had converted a permanent rename into a 64s timeout. A handler-reverted control run confirmed the PR's own changes weren't causal. Squash body re-armed to cover all of it. |
Patch76
left a comment
There was a problem hiding this comment.
The action string is right: on clears the gate for the light and for both fabricated IDs, so the round-4 rejection is gone. The block still does not execute, though, and the new assertion still cannot fail — both one level below where I looked last time.
[Concern 1]: bulk_device_control tries the component route first (device_control.py:665) and returns its response whenever that is not None (:668). Both component return paths build the response with an empty operation_ids — :1034 and :1136 pass [] into _build_bulk_response, since ops are confirmed inline and there is no polling handle. This batch qualifies for that route: three distinct entity IDs, all resolvable by _resolve_component_op, with bulk_call_service advertised (custom_components/ha_mcp_tools/websocket_api.py:387) and the component baked into the HAOS image (haos-e2e-tests.yml:151). So operation_ids is [], if operation_ids: (test_network_errors.py:374) is False, and the assertions at :392 and :408 are skipped. The test passes anyway because bulk_succeeded (:362) keys on the presence of operation_ids, which an empty list satisfies. The status-polling block is reachable only on the legacy fallback, i.e. when the component is absent.
It would not pin much there either. check_one (:1320) returns either get_device_operation_status, which produces only completed or pending, or an error entry whose status is error_code_to_status.get(code, "failed") — that default pins every remaining case into failed/timeout/not_found. The known set at :396 is exactly that closed vocabulary, so membership holds by construction, as the length check did. What round 4 asked for needs a value assertion — that the two fabricated IDs come back failed or not_found — on a path where operation_ids is populated.
[Concern 2]: get_domain_handler now replaces the action vocabulary per domain instead of extending it, so every domain whose table omits on/off/toggle loses actions that currently resolve to real services:
| entity domain | rejected after this PR | service it reaches today |
|---|---|---|
| scene | on | scene.turn_on |
| script | on, off | script.turn_on / turn_off |
| automation | on, off | automation.turn_on / turn_off |
| media_player | on, off | media_player.turn_on / turn_off |
| camera | on, off | camera.turn_on / turn_off |
| climate | toggle | climate.toggle |
All six exist on a 2026.7.4 instance. cover, lock, vacuum, alarm_control_panel, water_heater and the read-only domains lose entries too, but none of those resolved to an existing service, so dropping them is a straight improvement. Making the per-domain list a union with the default on/off/toggle keeps the commit purely additive and still reaches climate's heat.
[Concern 3]: The tables this commit makes reachable were never validated against service names, and _resolve_service_name does not bridge the gap, so 15 actions now pass validation and fail at dispatch where they were previously rejected with a structured error: light.adjust, climate.heat_cool (only heat/cool/auto map to set_hvac_mode), cover.stop (stop_cover), cover.set_temperature (set_cover_position), media_player.next and .previous (media_next_track/media_previous_track), fan.set_temperature (set_percentage), lock.turn_on — open is rewritten to turn_on for every non-cover domain although lock.open exists — alarm_control_panel.arm_home/arm_away/arm_night/disarm (alarm_arm_*/alarm_disarm), camera.stream (play_stream), scene.activate, and humidifier.set_temperature (set_humidity). None exist on a 2026.7.4 instance; for scene the new message offers activate alongside the one entry that does resolve.
Coverage: the new unit pins cover climate heat, light set, sensor's empty set and both fallbacks. Nothing pins that a table entry resolves to an existing service, and no test drives scene/script/automation/media_player/camera with on/off, which is why none of this shows up in CI.
Minor: the domain-handler change is a distinct user-facing bug from the bulk-status rework the title describes, and #2090 tracks it — worth linking from the body, which presents the lookup change without mentioning that the per-domain tables replace the default vocabulary.
Nit: docker_exec_in_addon hardcodes deadline = time.monotonic() + 45.0 beside the caller's timeout, where the sibling ssh_exec derives its own as max(timeout, 60.0); it also takes names[0] from a filter that can match both prefixes during a Supervisor migration.
Auto-merge is armed, so I am holding this at changes-requested: a populated-operation_ids path with a value assertion for Concern 1, the union for Concern 2, and a decision on Concern 3 (repair the mappings or trim the tables to what resolves) are what I need before this lands.
|
Claude suckz ... even more so today than on some other days |
…oses #2090) Review follow-ups (Patch76, round 5): - the per-domain tables replace the default handler's vocabulary, so domains whose tables predate the short on/off/toggle actions lost them when the bare-domain lookup went live: scene/script/automation/ media_player/camera on/off and climate toggle all resolve to real services and are restored as explicit table entries. Explicit rather than a blanket union: unioning on/off/toggle into every table would over-admit scene off/toggle, which have no services. - table entries the resolver could not map are either mapped or trimmed: cover stop/set, media_player next/previous, lock open, alarm arm_*/disarm, climate heat_cool now resolve to their real services; light adjust, fan/humidifier set, camera stream and scene activate are trimmed. The resolver is restructured into data-driven override tables (C901). - new e2e pin: every DOMAIN_HANDLERS action drives through _resolve_service_name and must exist in ha_list_services on the live instance, so a table entry can never again outrun the resolver or the HA service surface. Unit pins for the restored vocabulary, the trims, and each new mapping. - the mixed-entities e2e block asserts per-item accounting on the bulk RESPONSE (the only tier-independent surface: the component fast path confirms inline and returns no operation_ids), and value-asserts dispatched ids never report not_found; fabricated entities never receive operation ids on either tier, so their not_found VALUE contract stays pinned in test_list_operation_ids_invalid. - docker_exec_in_addon derives its deadline like ssh_exec and prefers the app_* container when both prefixes match mid-migration.
|
All three at aa6bfab, one deliberate divergence from the proposed fix, and one structural point on Concern 1. Concern 2: the six regressions are restored as explicit table entries rather than a union — unioning on/off/toggle into every non-read-only table would over-admit Concern 3: repaired where the service exists, trimmed where it doesn't. Mapped: cover stop→stop_cover and set→set_cover_position, media_player next/previous→media_track, lock open→open, alarm arm_home/arm_away/arm_night/disarm→alarm, climate heat_cool→set_hvac_mode. Trimmed: light adjust, fan set, humidifier set, camera stream, scene activate. The resolver is now data-driven override tables (the branch version tripped C901), and the coverage gap you named is closed by a new e2e test that drives every DOMAIN_HANDLERS action through _resolve_service_name and asserts the result exists in ha_list_services on the live instance — a table entry can no longer outrun the resolver or the service surface. This completes #2090. Concern 1: you're right that the component tier returns no operation_ids, and one level further, fabricated entities never receive ids on the legacy tier either — they're rejected before dispatch — so "fabricated ids come back failed/not_found in a populated-ids status call" is structurally unreachable from a real bulk dispatch on any tier. The block now value-asserts what each tier actually produces: per-item accounting on the bulk response itself (every requested entity accounted for in results/skipped_details — the abort-on-first-failure regression would break this on both tiers), and, when ids are populated, that the status call covers exactly the dispatched ids and none reports not_found (the one status a just-created id cannot legitimately have). The not_found value contract for unknown ids stays pinned in test_list_operation_ids_invalid. If you see a reachable path to a per-item failure inside a populated-ids batch, I'll pin that too. Nits: deadline now derives like ssh_exec's, and app_* is preferred when both prefixes match mid-migration. Squash body re-armed to cover the round. |
The unfiltered listing returns a FLAT services dict keyed "<domain>.<service>" and paginated to 50, so the pin saw only the first page and matched no domain key. Query one domain at a time with a generous limit and check the flat keys.
Patch76
left a comment
There was a problem hiding this comment.
The tables land correctly now. I resolved every DOMAIN_HANDLERS entry through _resolve_service_name and checked each resulting service against a 2026.7 instance; all of them exist. Concerns 2 and 3 from the last round are closed.
First, a correction to my own last round: I claimed the component route serves this batch and leaves operation_ids empty, and the comments added here follow that reading. It cannot serve it. _build_service_call keeps the entity's own domain (device_control.py:402), so the two fabricated IDs resolve to nonexistent.turn_on and invalid.turn_on, and the component runs all guards before any dispatch — an unknown service makes the whole frame raise (custom_components/ha_mcp_tools/websocket_api.py:5620-5623, documented at :5918). _bulk_via_component treats a pre-dispatch command error as a fallback signal and returns None (device_control.py:1007-1016), so this fixture always lands on the legacy path, operation_ids carries the real light, and the status block does execute.
[Concern 1]: The branch conflicts with master in tests/src/haos_runtime.py. #2092 landed the same container-name fix separately with a wider implementation — resolution through docker ps, a container-miss capture of docker ps -a and the Supervisor addon list, and a unit test — and it deliberately drops the retry window this branch adds. The copy bundled here is now redundant, so the clean resolution is to take master's file and drop this branch's haos_runtime.py changes entirely; the prefix preference that answers my earlier nit is already in master's resolver, and the deadline derivation stops applying once the retry window is gone. The green rollup predates that merge, so it certifies a merge base that no longer exists and only the post-rebase run counts. Auto-merge is armed with a commit body that still describes the bundled CI-infra fix; after the rebase that paragraph would land as a claim the squashed diff no longer carries.
[Concern 2]: The two new accounting assertions cannot fail. total_operations is len(operations) — the full requested list, skipped operations included (device_control.py:1261, passed raw at :730, :1052 and :1154) — so total_operations + len(skipped) at test_network_errors.py:377 double-counts and holds exactly when nothing was skipped. Every operation in this fixture carries both required fields (:350), so skipped is always empty and the assertion reduces to an identity; a malformed operation would make it fire spuriously instead. len(results) == total_operations is structural as well: _execute_parallel appends exactly one entry per valid operation for every outcome shape (device_control.py:1194-1207). The discriminating form is a value check on the same response — control_device_smart validates the target first and a missing entity becomes a structured ENTITY_NOT_FOUND (:135, :275), so successful_commands == len(valid_entities) and failed_commands == 2 pin the per-item contract as a value rather than a count.
Nit: test_every_table_action_resolves_to_a_live_service reads an empty service listing as "domain not loaded" and skips it, and assert checked > 0 only proves one domain was reached. Every action-carrying domain does load on the test instance today, so nothing is unchecked right now, but a domain that stops loading would drop out of the pin silently rather than fail it. Asserting the expected domain set would keep that visible.
Rebase onto master without the haos_runtime.py hunk, refresh the auto-merge commit body, and turn the accounting assertion into the value check. I will re-review on the new head.
|
Unavailable for at least 8 hours … weekly token limit reached. |
…ame fix in favor of #2092's wider version
|
Wow, I love how I pointed my other session at this PR and said "it can pass e2e, why can't yours?" It spent hours redoing the same fix this one already had and I didn't even realize it -_- new changes incoming |
…ain set Review follow-ups (Patch76, round 6): the accounting assertions held by construction (total_operations is the full requested list and _execute_parallel appends one result per operation) — replaced with value checks: the valid entities succeed and both fabricated entities fail as per-item entries. The comment claiming the component fast path serves this batch is corrected per the reviewer's own round-6 note: fabricated IDs resolve to unknown services, the component frame raises pre-dispatch and falls back, so the fixture always exercises the legacy path and the status block. The resolution pin now asserts every action-carrying domain was actually checked instead of silently skipping unloaded ones, and the locale-parity ceiling comment returns to master's 419 count (the Italian locale round added en.json keys, making this branch's 417 correction stale).
|
All at c8b1fb0, with CI green across all tiers. Concern 1: master merged in (merge rather than rebase — repo convention is never to force-push mid-review; the squash result is identical and threads stay anchored). This branch's haos_runtime.py changes are dropped entirely — the file is byte-identical to master's #2092 version — and the auto-merge commit body no longer claims the CI-infra fix. Concern 2: the two by-construction assertions are replaced with the value checks you specified: successful_commands == len(valid_entities) and failed_commands == 2, and the component-route comment is corrected per your own note — verified end to end that the fixture always lands on the legacy path (unknown domain passes the default handler's "on", rows build, the component's all-guards-first pass raises pre-dispatch, HomeAssistantCommandError → fallback), so the status block is live. Nit: the resolution pin now asserts checked_domains equals every action-carrying DOMAIN_HANDLERS domain — a domain dropping off the instance fails the pin instead of shrinking it. This run confirms all of them load on every tier. One mid-flight catch while merging: the auto-merge had kept this branch's "9 of 417" locale-ceiling comment over master's 419 — the Italian round added en.json keys, making our correction stale — restored to master's wording, verified by recomputation (de/fr at 9/419 = 2.1%). |
What does this PR do?
Started as the residual-dead-code sweep from #2043's final review; reviewing that sweep surfaced real defects in the operation-status path, which this PR also fixes.
Fixes (user-facing):
ha_get_operation_statuswith 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 thefailed/timeoutaggregation unreachable. Per-item errors now become structured entries indetailed_results, with anot_foundbucket in the summary.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_secondswindow.cleanup_expired_operationsnever reclaimed TIMEOUT-status operations (get_operation()flips an expired PENDING op to TIMEOUT in place on the read path, outside the mark-and-remove pass), so polled-after-timeout operations accumulated for the process lifetime. TIMEOUT now shares FAILED's 60s TTL (anchored oncompletion_time, and the sweep-marked path keeps the same terminal minute as the read path), and the overflow trim considers all terminal statuses — never in-flight PENDING — somax_operationsis enforceable except against a backlog of purely in-flight PENDING operations, which expire on their own timeouts.get_domain_handlerrequired a dot in its argument while both control paths pass a bare domain, so every per-domainvalid_actionstable was unreachable and the default handler's on/off/toggle applied everywhere — climate'sheat/cool/setwere rejected upfront as invalid actions. Closes ha_bulk_control accepts only on/off/toggle for every domain: get_domain_handler is called with a bare domain #2090. The lookup now accepts an entity ID or a bare domain, and the tables it makes reachable were made purely additive and resolvable: shorton/off/toggleactions restored where their services exist (scene/script/automation/media_player/camera, climate toggle), unmappable entries mapped (cover stop/set, media_player next/previous, lock open, alarm arm_*/disarm, climate heat_cool) or trimmed (light adjust, fan/humidifier set, camera stream, scene activate), with a new e2e test pinning every table action through the resolver against the live service list.Dead-code residuals from #2043 (internal):
settings_ui/locales/es.json: drop the orphanedadvanced.entity_search_limit.*keys —es.jsonmerged via feat: add the Spanish (es) locale across all four translated surfaces #2055 mid-review, so refactor(internal): remove dead code and stale references #2043's sweep never saw it; the help string still namedha_search_entities.tools/util_helpers.py: removestrip_internal_fields(its only production caller was the server bridge deleted in refactor(internal): remove dead code and stale references #2043); its rationale moves intopublic_fields' docstring.client/websocket_listener.py: drop the unreadself.settingsassignment and its import.tests/src/unit/test_websocket_listener.py: fixture no longer builds thestatsdict deleted in refactor(internal): remove dead code and stale references #2043.utils/operation_manager.py: removeOperationStatus.CANCELLED(unproducible sincecancel_operationwent; nothing ever read it).Tests: new unit pins for the cleanup TTLs and overflow trim (previously zero coverage), a projection pin for the area-only branch's
public_fieldsstrip, and real per-item assertions in the bulk-status e2e test.Type of change
Testing
uv run pytest)uv run ruff check)Checklist
CI-infra fix bundled (unblocks the inaddon leg repo-wide): Supervisor 2026.07.4 renamed addon containers from
addon_<slug>toapp_<slug>and the HAOS test VM self-updates Supervisor at boot, so the harness's hardcoded prefix broke every inaddon run once stable.json flipped.docker_exec_in_addonnow resolves the live container name per call.