Skip to content

fix: report per-item failures in bulk operation status instead of aborting the batch - #2076

Open
kingpanther13 wants to merge 17 commits into
masterfrom
chore/2043-residuals
Open

fix: report per-item failures in bulk operation status instead of aborting the batch#2076
kingpanther13 wants to merge 17 commits into
masterfrom
chore/2043-residuals

Conversation

@kingpanther13

@kingpanther13 kingpanther13 commented Jul 29, 2026

Copy link
Copy Markdown
Member

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_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, with a not_found bucket 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_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, outside the mark-and-remove pass), 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), and 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.

  • 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 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: short on/off/toggle actions 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):

Tests: 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.

Type of change

  • 🐛 Bug fix
  • ✨ New feature
  • 📚 Documentation
  • 🔧 Maintenance/refactor
  • 🧪 Tests only
  • 💥 Breaking change

Testing

  • I have tested these changes with a LLM agent
  • All automated tests pass (uv run pytest)
  • Code follows style guidelines (uv run ruff check)

Checklist

  • I have updated documentation if needed

CI-infra fix bundled (unblocks the inaddon leg repo-wide): Supervisor 2026.07.4 renamed addon containers from addon_<slug> to app_<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_addon now resolves the live container name per call.

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
@ghhamcp

ghhamcp commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@codex review — apply the review criteria in .gemini/styleguide.md in addition to AGENTS.md guidance

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 46e510338a

ℹ️ 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".

…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.
@kingpanther13 kingpanther13 changed the title refactor(internal): remove residual dead code left by #2043 fix: report per-item failures in bulk operation status instead of aborting the batch Jul 29, 2026
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.
@kingpanther13
kingpanther13 marked this pull request as ready for review July 29, 2026 15:15
@kingpanther13
kingpanther13 requested review from a team and Patch76 July 29, 2026 15:15
@ghhamcp

ghhamcp commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@codex review — apply the review criteria in .gemini/styleguide.md in addition to AGENTS.md guidance

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/ha_mcp/utils/operation_manager.py
Comment thread src/ha_mcp/tools/tools_service.py Outdated
Comment thread src/ha_mcp/tools/device_control.py Outdated
Comment thread src/ha_mcp/tools/device_control.py
Comment thread src/ha_mcp/tools/device_control.py
Comment thread tests/src/e2e/workflows/device_control/test_operation_status.py Outdated
- 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 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.operations

Appended, 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.
@kingpanther13

Copy link
Copy Markdown
Member Author

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.

@kingpanther13
kingpanther13 enabled auto-merge (squash) July 29, 2026 19:47

@Patch76 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) reddens test_operation_manager_cleanup.py::test_expired_pending_is_marked_timeout_and_kept_for_terminal_minute.
  • Narrowing the overflow trim at operation_manager.py:327 back to op.status == OperationStatus.COMPLETED reddens test_operation_manager_cleanup.py::test_overflow_trims_terminal_when_nothing_is_completed.
  • Reverting completion_percentage at device_control.py:1365-1366 to the successful fraction reddens test_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.
Comment thread tests/src/unit/test_bulk_operation_status_summary.py Fixed
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.
@kingpanther13

Copy link
Copy Markdown
Member Author

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 statuses read made the failed-ops count unconditionally zero, so it now asserts every requested ID appears in detailed_results and counts failed/timeout entries from there.

@Patch76 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@kingpanther13
kingpanther13 disabled auto-merge July 30, 2026 05:24
@kingpanther13
kingpanther13 enabled auto-merge (squash) July 30, 2026 05:24
@kingpanther13
kingpanther13 requested a review from Patch76 July 30, 2026 05:58
@kingpanther13

Copy link
Copy Markdown
Member Author

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 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@kingpanther13
kingpanther13 disabled auto-merge July 30, 2026 08:10
@kingpanther13
kingpanther13 enabled auto-merge (squash) July 30, 2026 08:10
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.
@kingpanther13
kingpanther13 disabled auto-merge July 30, 2026 09:00
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.
…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.
@kingpanther13
kingpanther13 enabled auto-merge (squash) July 30, 2026 09:29
@kingpanther13

Copy link
Copy Markdown
Member Author

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 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_onopen 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.

@Patch76

Patch76 commented Jul 30, 2026

Copy link
Copy Markdown
Member

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.
@kingpanther13
kingpanther13 disabled auto-merge July 30, 2026 13:54
@kingpanther13
kingpanther13 enabled auto-merge (squash) July 30, 2026 13:54
@kingpanther13

Copy link
Copy Markdown
Member Author

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 scene off and scene toggle, which have no services. Every restored entry names a service that exists.

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.
@kingpanther13
kingpanther13 requested a review from Patch76 July 30, 2026 14:19

@Patch76 Patch76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Patch76

Patch76 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Unavailable for at least 8 hours … weekly token limit reached.

@kingpanther13

kingpanther13 commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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).
@kingpanther13
kingpanther13 disabled auto-merge July 30, 2026 19:50
@kingpanther13
kingpanther13 enabled auto-merge (squash) July 30, 2026 19:50
@kingpanther13
kingpanther13 requested a review from Patch76 July 30, 2026 20:09
@kingpanther13

Copy link
Copy Markdown
Member Author

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%).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ha_bulk_control accepts only on/off/toggle for every domain: get_domain_handler is called with a bare domain

3 participants