Make long-running tools fire-and-return to fix intermittent timeouts

redeploy_project, create_project and pull_image used to block the tool
call while polling DSM until the project reached RUNNING / the image
appeared - up to 300s and 240s respectively. On large images this
regularly ran past Claude Desktop's ~4 min tool-call ceiling (the live
MCP log shows redeploy_project calls up to 260s), which the client
reported as a timeout even though the server kept working. Whether a
call crossed the ceiling depended on image size and NAS load, which is
why the failure was intermittent.

These tools now fire-and-return: they trigger the operation, consume
build_stream only for a short early-error window (20s, to catch fast
daemon errors like "manifest unknown"), then return a "running in the
background - check get_project_status / check_image_updates" hint.
Completion is observed via the existing fast status tools.

- DsmClient.trigger_build_stream gains a `budget` parameter
- remove _wait_for_project_running and the _POLL_*/_BUILD_POLL_TIMEOUT
  constants (projects.py) and _PULL_POLL_* constants (registry.py)
- update tests, CLAUDE.md DSM quirks and CHANGELOG; bump 0.7.0 -> 0.8.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 05:56:42 +02:00
parent 7bb9b00dcc
commit 4a49407883
9 changed files with 208 additions and 302 deletions
+10 -52
View File
@@ -285,30 +285,15 @@ async def test_pull_image_already_present():
@pytest.mark.asyncio
async def test_pull_image_confirmed_success(monkeypatch):
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
import mcp_synology_container.modules.registry as registry_mod
async def test_pull_image_confirmed_starts_background():
"""pull_start is invoked, then the tool returns immediately with a background hint —
it does NOT poll Image/list until the tag appears (fire-and-return)."""
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
state = {"pulled": False}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
if state["pulled"]:
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
return {"images": []}
return {"images": []} # not present locally → proceed to pull
if api == "SYNO.Docker.Image" and method == "pull_start":
state["pulled"] = True
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
@@ -319,11 +304,12 @@ async def test_pull_image_confirmed_success(monkeypatch):
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Pulled" in result
assert "started in the background" in result
assert "nginx:1.24" in result
assert "check_image_updates" in result
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
# with JSON-encoded params.
# with JSON-encoded params, exactly once.
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
assert len(pull_calls) == 1
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
@@ -332,37 +318,9 @@ async def test_pull_image_confirmed_success(monkeypatch):
assert json.loads(params["tag"]) == "1.24"
assert pull_calls[0].kwargs.get("version") == 1
@pytest.mark.asyncio
async def test_pull_image_timeout(monkeypatch):
"""If the image never appears, the tool returns a still-running hint."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
# Shrink the budget so the loop exits quickly
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "still running" in result
assert "nginx:1.24" in result
# Exactly one Image/list pre-check — no post-pull polling loop.
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
assert len(list_calls) == 1
@pytest.mark.asyncio