Skip to content

Commit a09bd94

Browse files
fix: retry Supervisor add-on calls on job-group collisions (#2041)
* fix: retry Supervisor add-on calls on job-group collisions Supervisor serialises jobs per add-on job group and rejects a state-changing call outright with "Another job is running for job group addon_<slug>" whenever a still-settling job (a watchdog restart, a prior start/stop, a store reload) still holds that group. ha_manage_addon surfaced that as SERVICE_CALL_FAILED with "Check Home Assistant connection and Supervisor availability", which is neither accurate nor actionable: nothing is wrong with the connection and the holder clears within seconds. Retry the call inside one bounded wall-clock window with backoff. The window is total rather than per-attempt, so a persistently blocked group cannot stretch a call into N * timeout. The collision is matched as a case-insensitive substring on Supervisor's own error text and every other failure raises on the first attempt, so this never masks a real error - the same containment the test runtime's dev-addon refresh already relies on for this collision. This also removes a real source of e2e flakiness: the add-on lifecycle tests drive stop/start/restart against a live Supervisor, so a sibling add-on job in flight could fail the run with this error. * fix(addons): anchor the collision marker and surface stuck-group guidance Review follow-ups on the job-group retry: - Anchor the marker on "another job is running for job group". Supervisor emits that phrasing from JobGroup.acquire for the transient per-add-on collision, but the job-level JobConcurrency.REJECT path raises a bare "Another job is running" for long OS/data-disk operations that must not ride a seconds-scale retry. No behaviour change for today's call sites (all add-on/store scoped), but the helper is generic. - Give up with a job-specific ToolError instead of falling through to the generic handler, which attached "Check Home Assistant connection and Supervisor availability" - the exact misleading suggestion this PR exists to remove, on the one path where it matters most. The give-up now logs at WARNING with attempt count and elapsed time, and carries the stuck-job guidance plus Supervisor's original error text. - Correct the comment: a genuine error arriving mid-retry raises immediately, not "on the first attempt". Drop the confusing N * timeout aside. - Document the retry in the docstring (the call can block for the window) and fix the stale Returns line: every failure raises, it never returns an error dict. - Tests: assert the real backoff schedule and its cap, pin the exact attempt count at the window bound, cover a real error mid-retry, the bare job-level rejection, timeout forwarding across retries, and that the give-up preserves Supervisor's text while dropping the connectivity hint. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent 9a24a35 commit a09bd94

2 files changed

Lines changed: 274 additions & 9 deletions

File tree

src/ha_mcp/tools/tools_addons.py

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,19 @@ def _merge_options(base: dict, override: dict) -> dict:
210210
return merged
211211

212212

213+
# Supervisor's per-job-group rejection, matched case-insensitively, plus the
214+
# bounded window and backoff used to ride it out. See _supervisor_api_call.
215+
# The "for job group" tail is load-bearing: JobGroup.acquire raises
216+
# "Another job is running for job group <name>" for the transient per-add-on
217+
# collision, while the job-level JobConcurrency.REJECT path raises a bare
218+
# "Another job is running" for long operations (OS update, data-disk wipe)
219+
# that must NOT be retried on this schedule.
220+
_JOB_COLLISION_MARKER = "another job is running for job group"
221+
_JOB_COLLISION_RETRY_WINDOW = 60.0
222+
_JOB_COLLISION_RETRY_INITIAL_DELAY = 1.0
223+
_JOB_COLLISION_RETRY_MAX_DELAY = 5.0
224+
225+
213226
async def _supervisor_api_call(
214227
client: HomeAssistantClient,
215228
endpoint: str,
@@ -228,8 +241,13 @@ async def _supervisor_api_call(
228241
data: Optional request body data
229242
timeout: Optional timeout override
230243
244+
A transient Supervisor job-group collision is retried with backoff, so a
245+
contended add-on endpoint can block for up to
246+
``_JOB_COLLISION_RETRY_WINDOW`` before returning or raising.
247+
231248
Returns:
232-
The "result" field from a successful response, or an error dict.
249+
``{"success": True, "result": ...}``. Every failure raises — this
250+
never returns an error dict.
233251
"""
234252
try:
235253
kwargs: dict[str, Any] = {"endpoint": endpoint, "method": method}
@@ -252,16 +270,78 @@ async def _supervisor_api_call(
252270
# ``{"success": False, "error": ...}``; re-raise it as the same
253271
# ``HomeAssistantCommandError`` the dedicated send_command used to raise
254272
# so the classifier below maps schema/not-found/etc. identically.
255-
result = await client.send_websocket_message(
256-
{"type": "supervisor/api", "_wait_timeout": wait_timeout, **kwargs}
257-
)
273+
#
274+
# Supervisor serialises jobs per add-on job group and rejects a
275+
# state-changing call outright while a still-settling job (a watchdog
276+
# restart, a prior start/stop, a store reload) holds that group. The
277+
# rejection happens before the job body runs, so nothing was applied
278+
# and retrying cannot double-execute. The holder clears within seconds
279+
# and the caller can do nothing useful with the failure, so ride it out
280+
# inside one bounded window. The window is total rather than
281+
# per-attempt, so a wedged group gives up once the window elapses
282+
# instead of after some fixed retry count. Any other failure raises
283+
# immediately, without retrying.
284+
deadline = time.monotonic() + _JOB_COLLISION_RETRY_WINDOW
285+
delay = _JOB_COLLISION_RETRY_INITIAL_DELAY
286+
attempts = 0
287+
while True:
288+
attempts += 1
289+
result = await client.send_websocket_message(
290+
{"type": "supervisor/api", "_wait_timeout": wait_timeout, **kwargs}
291+
)
292+
293+
if result.get("success"):
294+
return {"success": True, "result": result.get("result", {})}
295+
296+
error_text = str(
297+
result.get("error", f"Supervisor API call failed: {endpoint}")
298+
)
299+
if _JOB_COLLISION_MARKER not in error_text.lower():
300+
raise HomeAssistantCommandError(error_text)
301+
302+
remaining = deadline - time.monotonic()
303+
if remaining <= 0:
304+
# A group still held after the whole window is wedged, not
305+
# settling. Raise a ToolError here (the guard below re-raises
306+
# it untouched) so the caller gets guidance about the stuck
307+
# job instead of the generic connectivity suggestion the
308+
# exception handler attaches to every other failure.
309+
waited = _JOB_COLLISION_RETRY_WINDOW - remaining
310+
logger.warning(
311+
"Supervisor job group still busy on %s after %.0fs "
312+
"(%d attempts); giving up: %s",
313+
endpoint,
314+
waited,
315+
attempts,
316+
error_text,
317+
)
318+
raise_tool_error(
319+
create_error_response(
320+
ErrorCode.SERVICE_CALL_FAILED,
321+
error_text,
322+
context={
323+
"endpoint": endpoint,
324+
"attempts": attempts,
325+
"waited_seconds": round(waited, 1),
326+
},
327+
suggestions=[
328+
"Another job has held this add-on's job group for "
329+
f"over {_JOB_COLLISION_RETRY_WINDOW:.0f}s — check "
330+
"Supervisor logs for a stuck or long-running job",
331+
"Retry once the in-flight add-on operation "
332+
"(install, update, restart or backup) finishes",
333+
],
334+
)
335+
)
258336

259-
if not result.get("success"):
260-
raise HomeAssistantCommandError(
261-
str(result.get("error", f"Supervisor API call failed: {endpoint}"))
337+
logger.info(
338+
"Supervisor job-group collision on %s; retrying in %.1fs (%s)",
339+
endpoint,
340+
delay,
341+
error_text,
262342
)
263-
264-
return {"success": True, "result": result.get("result", {})}
343+
await asyncio.sleep(min(delay, remaining))
344+
delay = min(delay * 2, _JOB_COLLISION_RETRY_MAX_DELAY)
265345

266346
except ToolError:
267347
raise

tests/src/unit/test_tools_addons.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3768,6 +3768,191 @@ async def test_no_timeout_keeps_default_local_wait(self):
37683768
assert "timeout" not in message
37693769

37703770

3771+
class TestSupervisorJobGroupCollisionRetry:
3772+
"""Supervisor serialises jobs per add-on job group.
3773+
3774+
A state-changing call lands on "Another job is running for job group
3775+
addon_<slug>" whenever a still-settling job holds that group. The holder
3776+
clears within seconds, so the call is retried inside one bounded window
3777+
instead of surfacing a failure the caller can do nothing with. Every other
3778+
failure must raise immediately, without retrying.
3779+
"""
3780+
3781+
@staticmethod
3782+
def _collision(slug: str = "a0d7b954_appdaemon") -> dict:
3783+
return {
3784+
"success": False,
3785+
"error": f"Another job is running for job group addon_{slug}",
3786+
}
3787+
3788+
@pytest.mark.asyncio
3789+
async def test_job_group_collision_is_retried_until_it_clears(self):
3790+
"""The transient collision is retried and the eventual success returned."""
3791+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3792+
3793+
client = _make_mock_client()
3794+
client.send_websocket_message = AsyncMock(
3795+
side_effect=[
3796+
self._collision(),
3797+
self._collision(),
3798+
{"success": True, "result": {"state": "stopped"}},
3799+
]
3800+
)
3801+
3802+
sleep = AsyncMock()
3803+
with patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=sleep):
3804+
result = await _supervisor_api_call(
3805+
client, "/addons/a0d7b954_appdaemon/stop", method="POST"
3806+
)
3807+
3808+
assert result == {"success": True, "result": {"state": "stopped"}}
3809+
assert client.send_websocket_message.await_count == 3
3810+
# Backoff doubles from the initial delay; a flat or runaway schedule
3811+
# would still pass an outcome-only assertion.
3812+
assert [c.args[0] for c in sleep.await_args_list] == [1.0, 2.0]
3813+
3814+
@pytest.mark.asyncio
3815+
async def test_backoff_is_capped(self):
3816+
"""Delay doubles but never exceeds the max — an uncapped schedule would
3817+
overshoot the window on a long-held group."""
3818+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3819+
3820+
client = _make_mock_client()
3821+
client.send_websocket_message = AsyncMock(
3822+
side_effect=[self._collision()] * 5 + [{"success": True, "result": {}}]
3823+
)
3824+
3825+
sleep = AsyncMock()
3826+
with patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=sleep):
3827+
await _supervisor_api_call(
3828+
client, "/addons/a0d7b954_appdaemon/stop", method="POST"
3829+
)
3830+
3831+
assert [c.args[0] for c in sleep.await_args_list] == [1.0, 2.0, 4.0, 5.0, 5.0]
3832+
3833+
@pytest.mark.asyncio
3834+
async def test_other_failures_raise_without_retrying(self):
3835+
"""A non-collision failure must not be retried — it would mask the error."""
3836+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3837+
3838+
client = _make_mock_client()
3839+
client.send_websocket_message = AsyncMock(
3840+
return_value={"success": False, "error": "Addon is not installed"}
3841+
)
3842+
3843+
with (
3844+
patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=AsyncMock()),
3845+
pytest.raises(ToolError),
3846+
):
3847+
await _supervisor_api_call(
3848+
client, "/addons/a0d7b954_appdaemon/stop", method="POST"
3849+
)
3850+
3851+
assert client.send_websocket_message.await_count == 1
3852+
3853+
@pytest.mark.asyncio
3854+
async def test_real_error_after_a_collision_raises_immediately(self):
3855+
""" "Raises immediately" is per-failure, not just on the literal first
3856+
attempt: a genuine error arriving mid-retry must not be ridden out."""
3857+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3858+
3859+
client = _make_mock_client()
3860+
client.send_websocket_message = AsyncMock(
3861+
side_effect=[
3862+
self._collision(),
3863+
{"success": False, "error": "Addon is not installed"},
3864+
{"success": True, "result": {}},
3865+
]
3866+
)
3867+
3868+
with (
3869+
patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=AsyncMock()),
3870+
pytest.raises(ToolError) as exc_info,
3871+
):
3872+
await _supervisor_api_call(
3873+
client, "/addons/a0d7b954_appdaemon/stop", method="POST"
3874+
)
3875+
3876+
assert client.send_websocket_message.await_count == 2
3877+
assert "not installed" in str(exc_info.value).lower()
3878+
3879+
@pytest.mark.asyncio
3880+
async def test_bare_job_level_rejection_is_not_retried(self):
3881+
"""Supervisor's job-level REJECT ("Another job is running", no group
3882+
suffix) guards long OS/data-disk operations and must not ride this
3883+
seconds-scale schedule."""
3884+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3885+
3886+
client = _make_mock_client()
3887+
client.send_websocket_message = AsyncMock(
3888+
return_value={"success": False, "error": "Another job is running"}
3889+
)
3890+
3891+
with (
3892+
patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=AsyncMock()),
3893+
pytest.raises(ToolError),
3894+
):
3895+
await _supervisor_api_call(client, "/os/update", method="POST")
3896+
3897+
assert client.send_websocket_message.await_count == 1
3898+
3899+
@pytest.mark.asyncio
3900+
async def test_retry_forwards_timeout_on_every_attempt(self):
3901+
"""The Supervisor-side timeout and the extended local wait must ride
3902+
every retried attempt, not just the first."""
3903+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3904+
3905+
client = _make_mock_client()
3906+
client.send_websocket_message = AsyncMock(
3907+
side_effect=[self._collision(), {"success": True, "result": {}}]
3908+
)
3909+
3910+
with patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=AsyncMock()):
3911+
await _supervisor_api_call(
3912+
client,
3913+
"/addons/a0d7b954_appdaemon/restart",
3914+
method="POST",
3915+
timeout=120,
3916+
)
3917+
3918+
for call in client.send_websocket_message.await_args_list:
3919+
assert call.args[0]["timeout"] == 120
3920+
assert call.args[0]["_wait_timeout"] == 135.0
3921+
3922+
@pytest.mark.asyncio
3923+
async def test_retry_window_is_bounded(self):
3924+
"""A group that never clears gives up rather than retrying forever, and
3925+
surfaces the stuck-job guidance instead of the generic connectivity
3926+
suggestion the exception handler attaches to every other failure."""
3927+
from ha_mcp.tools.tools_addons import _supervisor_api_call
3928+
3929+
client = _make_mock_client()
3930+
client.send_websocket_message = AsyncMock(return_value=self._collision())
3931+
3932+
# monotonic() is called once for the deadline, then once per failed
3933+
# attempt; 20s ticks exhaust the 60s window on the third attempt.
3934+
ticks = iter([0.0] + [float(i) * 20.0 for i in range(1, 40)])
3935+
with (
3936+
patch("ha_mcp.tools.tools_addons.asyncio.sleep", new=AsyncMock()),
3937+
patch(
3938+
"ha_mcp.tools.tools_addons.time.monotonic",
3939+
side_effect=lambda: next(ticks),
3940+
),
3941+
pytest.raises(ToolError) as exc_info,
3942+
):
3943+
await _supervisor_api_call(
3944+
client, "/addons/a0d7b954_appdaemon/stop", method="POST"
3945+
)
3946+
3947+
assert client.send_websocket_message.await_count == 3
3948+
payload = json.loads(str(exc_info.value))["error"]
3949+
# Supervisor's own text survives the give-up path...
3950+
assert "another job is running for job group" in payload["message"].lower()
3951+
# ...and the guidance names the stuck job, not the connection.
3952+
assert any("Supervisor logs" in s for s in payload["suggestions"])
3953+
assert not any("connection" in s.lower() for s in payload["suggestions"])
3954+
3955+
37713956
class TestManageAddonActionMode:
37723957
"""Lifecycle (action) mode: install/start/stop/etc. via Supervisor."""
37733958

0 commit comments

Comments
 (0)