Commit 8944595
fix: Schedule the HACS refresh nudge via the FastMCP lifespan (#2172)
* fix: Schedule the HACS refresh nudge via the FastMCP lifespan
The startup nudge was scheduled in __main__'s _run_with_shutdown, which
the CLI entry points share — but the HA add-on's start.py calls
mcp.run() directly and never passes through it, so the nudge never ran
on the add-on, the deployment it was built for. Found on a live HAOS
instance; CI could not see it because the e2e suites launch via the CLI
entry points.
Attach the task as the server's FastMCP lifespan instead: the lifespan
manager is entered by every transport runner (stdio and the HTTP app
factories), ref-counted to fire once per run, so one site covers stdio,
web, oauth, oidc, and the add-on launcher. The lifespan's exit cancels
a nudge parked in a retry sleep, replacing the _cancel_tasks wiring.
* test: Prove the startup nudge end to end through a real launcher
Boot the actual ha-mcp stdio binary against the e2e container and watch
for the refresh marker in its data dir — the observable end of the
process -> lifespan -> task -> WebSocket -> HACS -> marker chain. This
is the regression shape of the add-on gap: the unit suite pins the
lifespan wiring, but only a real launcher shows the chain completing,
which is exactly what stayed invisible to CI when the scheduling site
was launcher-specific.
* chore: Allowlist the lifespan's cancelled-task await in the CodeQL gate
Same py/ineffectual-statement false positive as the embedded teardown
entries: the bare await inside contextlib.suppress drives the cancelled
nudge task to completion before the server lifespan exits.
* fix(internal): Log a due startup nudge before any WebSocket work
One unconditional INFO line when a pass is due, emitted ahead of the
first WebSocket call: it proves the launcher scheduled the nudge even
where HACS is absent and the pass ends silently — the observable the
HAOS add-on lane greps for, and the line whose absence exposed the
launcher gap. The not-due hot path stays log-silent; both sides pinned
in the unit suite.
* test: Cover every launcher lane's startup-nudge contract
- ha-mcp-web positive lane: the HTTP lifespan fires at uvicorn startup
with no client, and the marker appears.
- ha-mcp-oauth negative lane: the pass returns at the OAuth-sentinel
gate — no past-the-gate log lines, no marker — instead of burning the
retry schedule on auth failures every boot.
- Embedded negative lane: the in-process server writes no marker (the
is_embedded gate; the component's own hacs_nudge covers embedded).
- HAOS add-on lane: the inaddon tier greps the dev add-on's own logs
for the due-pass INFO line — the real launcher that had the gap.
- ha-mcp-oidc deliberately has no lane: it exits without HA credentials
at startup, so it has no sentinel state to prove, and its run path is
the same call the web lane drives.
- stdio lane: keep_alive=False so the subprocess ends with the client
context instead of leaking past it (review finding).
- Backend dispatch skip ceilings bumped for the two new marker-gated
tests.
* chore: Scope CodeQL allowlist entries to the flagged statement
Generic-message rules (py/ineffectual-statement) previously suppressed
path-wide. Entries now carry an optional code substring matched against
the flagged line's text read from the checkout — only the named
statement is suppressed, any other finding of the rule in the file
still fails the gate, and content matching means edits above the
statement don't churn the allowlist. Fails closed when the source line
cannot be read (review finding).
* fix: Make the inaddon nudge probe restart-first; address review findings
The inaddon lane failure was the probe, not the feature: the run's
diagnostics artifact shows the due-pass line in the add-on log — the
lifespan fired on the real add-on launcher — but the test ran at the end
of the suite, by which point the one-shot boot line sat thousands of
request-log lines beyond even the expanded journald search window. The
test now self-restarts the add-on first (the test_addon_debug_log_level
pattern: settings restart endpoint, fresh connection per poll, shared
client warmed back up) so the line is in the fresh tail, and probes with
safe_call_tool so a briefly unavailable Supervisor log endpoint counts
as a failed poll instead of aborting the wait.
Also from review:
- CodeQL allowlist entry for the e2e helper's cancelled-racer await
(same statement-scoped false-positive class).
- haos skip-ceiling comment arithmetic corrected (was 46, not 47).
- Negative-lane marker windows widened to 10 s with comments anchoring
them to first-attempt completion, not the retry schedule.
- The unreachable-HACS unit test now asserts the due-pass line logged
although every WS attempt failed — the strongest before-any-WS-work
proof, and the property the add-on lane depends on.
* test: Use MCPAssertions for the inaddon slug lookup
call_tool_success per the test conventions: a failed ha_get_addon
listing reports itself instead of reading as a missing dev add-on.
* fix: Make the inaddon nudge probe marker-state-independent
The restart-first probe assumed a fresh add-on boot is always due; the
run's diagnostics disproved that — the add-on's /data persists, an early
boot that lives ~8.5 minutes completes its HACS-absent pass and writes
the marker, and every later boot is legitimately not due (50 boots, one
due-line). The not-due return now emits a DEBUG line, and the test
drives the add-on to DEBUG via the settings flow, restarts, and accepts
either per-boot line as scheduling proof, restoring INFO after. Budgets
sized inside pytest-timeout's 300 s cap, which the previous version
could exceed and mask its own failure.
* test: Probe the shared-client warmup with safe_call_tool
A ToolError raised mid-bounce is not in the transient set and would
escape the warm-up loop; probe with safe_call_tool and break only on a
successful payload, raising a named assertion at the deadline.
* style: Explain the transient pass in the warmup probe (py/empty-except)
* test: Give the inaddon nudge probe real HAOS budgets
The 60 s warm-up starved in CI: the flow does two back-to-back
container restarts, and DEBUG-level logging makes every Supervisor log
fetch heavy. Budgets now reflect observed recovery times (probe 180,
restore 120, warm 180) with a per-test pytest.mark.timeout sized to the
phase sum plus restart margin, per the other HAOS long-runners.
* test: Warm the shared client on any completed round-trip
Payload inspection in the warm-up proved harmful — it kept the loop
spinning against a healthy session through two CI rounds. A completed
round-trip, success or ToolError, is what proves the shared session
usable again (the debug-log-level precedent); only transport-level
transients retry, and the deadline assertion now names the last error.
The probe also post-filters for the two real per-boot phrases so its
own DEBUG-logged search argument cannot satisfy it.
* test: Bound every await in the inaddon nudge probe
Round-4 CI hit pytest-timeout with none of the test's own deadlines
firing: the event loop sat idle at selector.select on an unbounded
streamable-HTTP read against the bouncing addon. Every MCP exchange —
fresh-client probe calls and the shared-client warmup — now runs under
asyncio.wait_for(30); TimeoutError is in the transient set, so a bound
trip is retried and the phase deadlines actually enforce.
* test: Warm-up via safe_call_tool, deadline-capped iterations
Both from review: safe_call_tool inside the bound (a returned dict of
either shape is the completed round-trip warm-up needs), and the last
iteration's wait and sleep are capped to the remaining budget so the
loop cannot overshoot _WARM_TIMEOUT.
* test: Pin HACS startup nudge boot evidence
* test: Stabilize the nudge log baseline
* test: Bind nudge proof to restarted process
---------
Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>1 parent 47a20f1 commit 8944595
12 files changed
Lines changed: 1154 additions & 72 deletions
File tree
- scripts
- src/ha_mcp
- tests/src
- e2e
- basic
- haos_only
- workflows
- embedded
- hacs
- unit
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
666 | 666 | | |
667 | 667 | | |
668 | 668 | | |
669 | | - | |
670 | | - | |
671 | | - | |
672 | | - | |
673 | | - | |
674 | | - | |
675 | | - | |
676 | 669 | | |
677 | 670 | | |
678 | 671 | | |
| |||
732 | 725 | | |
733 | 726 | | |
734 | 727 | | |
735 | | - | |
| 728 | + | |
736 | 729 | | |
737 | 730 | | |
738 | 731 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
28 | 30 | | |
29 | 31 | | |
30 | 32 | | |
| |||
222 | 224 | | |
223 | 225 | | |
224 | 226 | | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
225 | 236 | | |
226 | 237 | | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
227 | 249 | | |
228 | 250 | | |
229 | 251 | | |
| |||
250 | 272 | | |
251 | 273 | | |
252 | 274 | | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
142 | 143 | | |
143 | 144 | | |
144 | 145 | | |
| 146 | + | |
145 | 147 | | |
146 | 148 | | |
147 | 149 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
87 | | - | |
88 | | - | |
89 | | - | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
103 | | - | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
121 | | - | |
| 121 | + | |
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| |||
0 commit comments