Skip to content

Commit f34ea9a

Browse files
Patch76claude
andcommitted
refactor: address PR #1398 KP13 review (8 of 9 items + retune)
- entity_not_verified detection via warnings-substring scan (#1297-popped flag was dead — VERDICT=VALIDATED could leak silently) - sample-count invariant: len(samples) + not_verified == N_SAMPLES - rename "p99" -> "worst" (at N=10 the percentile collapses to max; the threshold-check name now reflects what it measures) - per-test try/finally cleanup: cleanup_tracker is a logging-only no-op, so 10 automations were leaking into the next worker run - caplog.at_level context manager (set_level mutated worker-wide level and leaked DEBUG-on-rest_client into every later test) - 6 comment subtractions per Boy-Scout Boy-Scout retune from #1389 measurement (RETUNE NEEDED p50>=100ms): - _POLL_CADENCE: (0.1, 1.0, 4.9) -> (0.025, 1.0, 4.975) — first-poll pulled to a 5x cushion above the measured ~4ms HA-Core entity-registration latency - unit-test pins updated to the new cadence values The HAOS proxy-header flake item from KP13's review landed independently on master via #1402 (_wait_addon_running guard); intentionally not duplicated here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 247c25c commit f34ea9a

4 files changed

Lines changed: 181 additions & 145 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -964,9 +964,12 @@ async def upsert_automation_config(
964964
) from e
965965
raise
966966

967-
# 3-attempt × 6s upper-bound budget; first poll 0.1s catches the
968-
# typical sub-1s entity-publish window.
969-
_POLL_CADENCE: tuple[float, ...] = (0.1, 1.0, 4.9)
967+
# 3-attempt × 6s upper-bound budget; first poll 0.025s is a 5×
968+
# cushion above the ~4ms HA-Core entity-registration latency
969+
# measured by ``test_poll_cadence_measurement.py`` (#1389 — p50
970+
# 104.1-104.8 ms on the prior 0.1s first-poll, all from the sleep
971+
# itself with ~4 ms of real registration work).
972+
_POLL_CADENCE: tuple[float, ...] = (0.025, 1.0, 4.975)
970973

971974
async def _poll_for_automation_entity(self, unique_id: str) -> str | None:
972975
"""Poll HA state to find the entity_id assigned to a newly created automation."""

tests/src/e2e/conftest.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,18 @@
8989
_READINESS_TIMINGS: list[dict[str, Any]] = []
9090
_ALL_READINESS_TIMINGS: list[dict[str, Any]] = []
9191

92-
# Same shape as ``_READINESS_TIMINGS``, parallel channel for the #1389
93-
# ``_POLL_CADENCE`` measurement. Lives at this conftest level (not deeper)
94-
# because pytest-xdist's master only loads conftests up to where tests
95-
# are collected; deeper subdir conftests don't get their hooks invoked
96-
# on the master, which is where ``pytest_terminal_summary`` writes to
97-
# the visible terminal output. See ``record_poll_cadence_measurement``
98-
# call site in ``workflows/automation/test_poll_cadence_measurement.py``.
92+
# Parallel channel for the #1389 ``_POLL_CADENCE`` measurement —
93+
# mirrors the xdist round-trip used by ``_READINESS_TIMINGS`` above.
9994
_POLL_CADENCE_MEASUREMENTS: list[dict[str, Any]] = []
10095
_ALL_POLL_CADENCE_MEASUREMENTS: list[dict[str, Any]] = []
10196

10297

10398
def record_poll_cadence_measurement(measurement: dict[str, Any]) -> None:
10499
"""Record a single #1389 measurement run from a test method.
105100
106-
Expected keys: ``n``, ``attempts``, ``p50``, ``p90``, ``p99``, ``min``,
107-
``max``, ``not_verified``, ``verdict``, ``samples``.
101+
See the call site in
102+
``workflows/automation/test_poll_cadence_measurement.py`` for the
103+
measurement dict shape — that file owns the schema.
108104
"""
109105
_POLL_CADENCE_MEASUREMENTS.append(measurement)
110106

@@ -264,7 +260,8 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
264260
for m in poll_measurements:
265261
terminalreporter.write_line(
266262
f"[POLL_CADENCE_1389] N={m['n']}/{m.get('attempts', m['n'])} "
267-
f"p50={m['p50']:.1f}ms p90={m['p90']:.1f}ms p99={m['p99']:.1f}ms "
263+
f"p50={m['p50']:.1f}ms p90={m['p90']:.1f}ms "
264+
f"worst={m['worst']:.1f}ms "
268265
f"min={m['min']:.1f}ms max={m['max']:.1f}ms "
269266
f"not_verified={m['not_verified']} VERDICT={m['verdict']}"
270267
)
Lines changed: 158 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Measurement test for issue #1389 — _POLL_CADENCE p50/p99 validation.
1+
"""Measurement test for issue #1389 — _POLL_CADENCE p50/worst validation.
22
33
This test creates ``N=10`` automations cold-start, captures the
44
``entity-registration-elapsed`` DEBUG log line emitted by
@@ -12,9 +12,11 @@
1212
sharing one HA testcontainer would make a strict threshold-assert
1313
flaky). The "VERDICT" line in the output makes the result machine-greppable.
1414
15-
Decision rule (per #1389):
16-
- ``p50 < 100 ms`` AND ``p99 < 1.0 s`` → cadence validated, close #1389
17-
- otherwise → open follow-up retune PR with the measurement table
15+
The headline metric is ``worst`` (highest observed sample) rather than
16+
a true statistical p99: at N=10 the 99th-percentile index collapses to
17+
the same bucket as p90, so the field would silently drop the
18+
outlier-detection role it was named for. ``worst`` reflects what the
19+
threshold-check actually measures.
1820
"""
1921

2022
import logging
@@ -32,6 +34,15 @@
3234
# on every successful registration. Format owned by that function.
3335
_ELAPSED_RE = re.compile(r"entity-registration-elapsed:\s*([\d.]+)ms")
3436

37+
# Distinctive substring of the soft-failure warning ``ha_config_set_automation``
38+
# emits when ``_poll_for_automation_entity`` exhausts ``_POLL_CADENCE`` without
39+
# matching the new automation. The tool pops ``entity_not_verified`` from the
40+
# response, so this warning is the only reliable signal that a registration
41+
# missed. Mirrors the literal in ``tools_config_automations.py``.
42+
_NOT_VERIFIED_WARNING_PREFIX = (
43+
"Automation was submitted to Home Assistant but the entity was not found"
44+
)
45+
3546

3647
@pytest.mark.automation
3748
@pytest.mark.cleanup
@@ -51,128 +62,151 @@ class TestPollCadenceMeasurement1389:
5162

5263
N_SAMPLES = 10
5364

54-
async def test_poll_cadence_p50_p99(
65+
async def test_poll_cadence_p50_worst(
5566
self, mcp_client, cleanup_tracker, test_data_factory, caplog
5667
):
57-
# Capture the DEBUG records emitted by ``_poll_for_automation_entity``
58-
# during this test only. ``caplog`` is per-test-method-scoped, so
59-
# records emitted by other tests in the same worker (before or
60-
# after this one) don't appear in ``caplog.records`` here. The
61-
# rest_client logger-name filter + regex below narrow to the
62-
# elapsed-ms records specifically, and ``caplog.clear()`` at entry
63-
# is belt-and-suspenders for any setup-phase noise.
64-
caplog.set_level(logging.DEBUG, logger="ha_mcp.client.rest_client")
65-
66-
# Clear any pre-existing matching records so a long-running worker's
67-
# earlier automation creates don't leak into our sample set.
68-
caplog.clear()
69-
70-
not_verified_count = 0
71-
for i in range(self.N_SAMPLES):
72-
config = test_data_factory.automation_config(
73-
f"Poll Cadence Measurement 1389-{i:02d}",
68+
# ``caplog.at_level`` saves+restores the logger level on exit;
69+
# ``caplog.set_level`` would leave DEBUG-on-rest_client sticky
70+
# for every later test on the same xdist worker. Wraps only the
71+
# create-loop so unrelated DEBUG noise from teardown stays out.
72+
created_entity_ids: list[str] = []
73+
try:
74+
with caplog.at_level(logging.DEBUG, logger="ha_mcp.client.rest_client"):
75+
caplog.clear()
76+
not_verified_count = 0
77+
for i in range(self.N_SAMPLES):
78+
config = test_data_factory.automation_config(
79+
f"Poll Cadence Measurement 1389-{i:02d}",
80+
)
81+
create_data = await safe_call_tool(
82+
mcp_client, "ha_config_set_automation", {"config": config}
83+
)
84+
assert create_data.get("success"), (
85+
f"automation creation #{i} failed: {create_data}"
86+
)
87+
entity_id = create_data.get("entity_id")
88+
if (
89+
entity_id
90+
and isinstance(entity_id, str)
91+
and entity_id.startswith("automation.")
92+
):
93+
cleanup_tracker.track("automation", entity_id)
94+
created_entity_ids.append(entity_id)
95+
# ``ha_config_set_automation`` pops ``entity_not_verified``
96+
# off the response before returning and translates the
97+
# miss into a ``warnings`` entry — scan that instead.
98+
# Missing this signal would let a "RETUNE NEEDED" run
99+
# silently print VERDICT=VALIDATED.
100+
warnings = create_data.get("warnings") or []
101+
if any(
102+
isinstance(w, str)
103+
and w.startswith(_NOT_VERIFIED_WARNING_PREFIX)
104+
for w in warnings
105+
):
106+
not_verified_count += 1
107+
108+
# Parse elapsed-ms values out of captured DEBUG records.
109+
samples: list[float] = []
110+
for record in caplog.records:
111+
if record.name != "ha_mcp.client.rest_client":
112+
continue
113+
match = _ELAPSED_RE.search(record.getMessage())
114+
if match:
115+
samples.append(float(match.group(1)))
116+
117+
assert samples, (
118+
f"No 'entity-registration-elapsed' DEBUG records captured after "
119+
f"{self.N_SAMPLES} automation creations. Either the instrumentation "
120+
f"is missing, the logger name changed, or the format regex drifted."
121+
)
122+
123+
# Sample-count invariant pairs with the miss detection above:
124+
# every successful iteration must contribute exactly one elapsed
125+
# record. A partial capture (9 samples + 1 miss not detected by
126+
# the warning scan) would silently skew percentiles otherwise.
127+
assert len(samples) + not_verified_count == self.N_SAMPLES, (
128+
f"sample-count invariant violated: {len(samples)} samples + "
129+
f"{not_verified_count} not-verified != {self.N_SAMPLES} attempts"
130+
)
131+
132+
samples.sort()
133+
n = len(samples)
134+
p50 = statistics.median(samples)
135+
p90 = samples[max(0, int(0.9 * n) - 1)]
136+
# At N=10, ``int(0.99 * (n-1))`` collapses to the same index as
137+
# p90 — a real statistical p99 needs more samples. Use the
138+
# highest observed sample under the name ``worst`` so the
139+
# decision-rule threshold reflects what it actually checks.
140+
worst = samples[-1]
141+
s_min = samples[0]
142+
s_max = samples[-1]
143+
144+
p50_ok = p50 < 100.0
145+
worst_ok = worst < 1000.0
146+
no_misses = not_verified_count == 0
147+
verdict = (
148+
"VALIDATED" if (p50_ok and worst_ok and no_misses) else "RETUNE NEEDED"
149+
)
150+
151+
# ``logger.info`` from a test method is captured by pytest-xdist's
152+
# per-worker buffer and only surfaces on FAILURE — PASSED tests
153+
# silently drop the INFO output. Route through the conftest
154+
# recorder so ``pytest_terminal_summary`` renders the table on
155+
# the master, outside the capture buffer.
156+
record_poll_cadence_measurement(
157+
{
158+
"n": n,
159+
"attempts": self.N_SAMPLES,
160+
"p50": p50,
161+
"p90": p90,
162+
"worst": worst,
163+
"min": s_min,
164+
"max": s_max,
165+
"not_verified": not_verified_count,
166+
"verdict": verdict,
167+
"samples": [round(s, 1) for s in samples],
168+
}
169+
)
170+
171+
sep = "=" * 70
172+
logger.info(sep)
173+
logger.info(
174+
"#1389 _POLL_CADENCE measurement (N=%d successful samples / %d attempts, "
175+
"%d not-verified)",
176+
n,
177+
self.N_SAMPLES,
178+
not_verified_count,
74179
)
75-
create_data = await safe_call_tool(
76-
mcp_client, "ha_config_set_automation", {"config": config}
180+
logger.info(sep)
181+
logger.info(
182+
"p50 = %6.1f ms (rule: < 100ms → %s)",
183+
p50,
184+
"PASS" if p50_ok else "RETUNE",
77185
)
78-
assert create_data.get("success"), (
79-
f"automation creation #{i} failed: {create_data}"
186+
logger.info("p90 = %6.1f ms", p90)
187+
logger.info(
188+
"worst = %6.1f ms (rule: < 1000ms → %s)",
189+
worst,
190+
"PASS" if worst_ok else "RETUNE",
80191
)
81-
entity_id = create_data.get("entity_id")
82-
if (
83-
entity_id
84-
and isinstance(entity_id, str)
85-
and entity_id.startswith("automation.")
86-
):
87-
cleanup_tracker.track("automation", entity_id)
88-
# Track misses — when _poll_for_automation_entity returns None
89-
# after exhausting _POLL_CADENCE, the create_data carries this
90-
# flag and no elapsed-ms record is emitted for that iteration.
91-
# Such misses are themselves evidence that the cadence is too
92-
# short (p99 >= sum(_POLL_CADENCE) = 6.0s), so a high miss
93-
# rate flips the decision toward RETUNE.
94-
if create_data.get("entity_not_verified"):
95-
not_verified_count += 1
96-
97-
# Parse elapsed-ms values out of captured DEBUG records.
98-
samples: list[float] = []
99-
for record in caplog.records:
100-
if record.name != "ha_mcp.client.rest_client":
101-
continue
102-
match = _ELAPSED_RE.search(record.getMessage())
103-
if match:
104-
samples.append(float(match.group(1)))
105-
106-
assert samples, (
107-
f"No 'entity-registration-elapsed' DEBUG records captured after "
108-
f"{self.N_SAMPLES} automation creations. Either the instrumentation "
109-
f"is missing, the logger name changed, or the format regex drifted."
110-
)
111-
112-
samples.sort()
113-
n = len(samples)
114-
p50 = statistics.median(samples)
115-
p90 = samples[max(0, int(0.9 * n) - 1)]
116-
# For small ``N_SAMPLES`` (=10), ``int(0.99 * (n-1))`` collapses to
117-
# the same index as p90 (both → 8 for n=10), silently dropping the
118-
# worst-case sample. Use the maximum as p99 so an outlier can't
119-
# violate the decision-rule threshold without surfacing.
120-
p99 = samples[-1]
121-
s_min = samples[0]
122-
s_max = samples[-1]
123-
124-
# Decision rule per #1389 body. Any not-verified iteration is itself
125-
# a p99 ≥ 6.0s signal (cadence exhausted without match) and flips to
126-
# RETUNE regardless of the measured samples.
127-
p50_ok = p50 < 100.0
128-
p99_ok = p99 < 1000.0
129-
no_misses = not_verified_count == 0
130-
verdict = "VALIDATED" if (p50_ok and p99_ok and no_misses) else "RETUNE NEEDED"
131-
132-
# ``logger.info`` from a test method is captured by pytest-xdist's
133-
# per-worker buffer and only surfaces on FAILURE — PASSED tests
134-
# silently drop the INFO output. Route through the conftest
135-
# recorder so ``pytest_terminal_summary`` renders the table on
136-
# the master, outside the capture buffer. Logger calls below
137-
# remain for local ad-hoc runs (where xdist is off and INFO
138-
# streams to the terminal directly).
139-
record_poll_cadence_measurement(
140-
{
141-
"n": n,
142-
"attempts": self.N_SAMPLES,
143-
"p50": p50,
144-
"p90": p90,
145-
"p99": p99,
146-
"min": s_min,
147-
"max": s_max,
148-
"not_verified": not_verified_count,
149-
"verdict": verdict,
150-
"samples": [round(s, 1) for s in samples],
151-
}
152-
)
153-
154-
sep = "=" * 70
155-
logger.info(sep)
156-
logger.info(
157-
"#1389 _POLL_CADENCE measurement (N=%d successful samples / %d attempts, "
158-
"%d not-verified)",
159-
n,
160-
self.N_SAMPLES,
161-
not_verified_count,
162-
)
163-
logger.info(sep)
164-
logger.info(
165-
"p50 = %6.1f ms (rule: < 100ms → %s)",
166-
p50,
167-
"PASS" if p50_ok else "RETUNE",
168-
)
169-
logger.info("p90 = %6.1f ms", p90)
170-
logger.info(
171-
"p99 = %6.1f ms (rule: < 1000ms → %s)",
172-
p99,
173-
"PASS" if p99_ok else "RETUNE",
174-
)
175-
logger.info("min = %6.1f ms / max = %6.1f ms", s_min, s_max)
176-
logger.info("samples (sorted, ms): %s", [round(s, 1) for s in samples])
177-
logger.info("VERDICT: %s", verdict)
178-
logger.info(sep)
192+
logger.info("min = %6.1f ms / max = %6.1f ms", s_min, s_max)
193+
logger.info("samples (sorted, ms): %s", [round(s, 1) for s in samples])
194+
logger.info("VERDICT: %s", verdict)
195+
logger.info(sep)
196+
finally:
197+
# The session-scoped ``cleanup_tracker`` fixture only logs what
198+
# it tracked; without an explicit delete here this test would
199+
# leak 10 automations into the next worker run on a loadscope
200+
# split. Best-effort: per-entity remove, swallow individual
201+
# failures so cleanup doesn't mask the real assertion outcome.
202+
for ent_id in created_entity_ids:
203+
try:
204+
await safe_call_tool(
205+
mcp_client,
206+
"ha_config_remove_automation",
207+
{"identifier": ent_id},
208+
)
209+
except Exception as cleanup_err:
210+
logger.warning(
211+
"cleanup: failed to remove %s: %s", ent_id, cleanup_err
212+
)

0 commit comments

Comments
 (0)