diff --git a/README.md b/README.md index 6a124c2..2f92d70 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ uv run jira-hub import ../admin/jira-sync | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | -| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту | +| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту; ожидание асинхронное, сервер не блокируется | | `batches` | `limit` | последние пакеты | | `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | -| `refresh` | `keys` или `full`, `wait` | `sync_run` или id пакета | +| `refresh` | `keys` или `full`, `wait` (по умолчанию `false`: только ставит пакет; `true` — ждёт до 30 мин) | id пакета или итог пакета | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | | `sync_log` | `limit` | последние загрузки состояния | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index b7ceb61..43106a0 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -1,6 +1,8 @@ """MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" +import asyncio import functools +import inspect import json import time @@ -18,7 +20,23 @@ def _client() -> HubClient: return HubClient.from_home() +def _hub_error(e: HubError) -> str: + return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + + def guard(fn): + """Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции.""" + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def awrapper(*args, **kwargs): + try: + return await fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return _hub_error(e) + return awrapper + @functools.wraps(fn) def wrapper(*args, **kwargs): try: @@ -26,7 +44,7 @@ def guard(fn): except HubUnavailable: return OFFLINE except HubError as e: - return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + return _hub_error(e) return wrapper @@ -138,15 +156,15 @@ def batch_status(id: int) -> str: @mcp.tool() @guard -def batch_wait(id: int, timeout_minutes: int = 10) -> str: - """Ждать завершения пакета (до 60 мин), вернуть итог по командам.""" +async def batch_wait(id: int, timeout_minutes: int = 10) -> str: + """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 client = _client() while True: - b = client.get(f"/batches/{id}") + b = await asyncio.to_thread(client.get, f"/batches/{id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") - time.sleep(POLL_SECONDS) + await asyncio.sleep(POLL_SECONDS) @mcp.tool() @@ -174,12 +192,13 @@ def batch_cancel(id: int) -> str: @mcp.tool() @guard -def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = True) -> str: - """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). wait — дождаться результата.""" +async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: + """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; + wait=true — дождаться результата (до 30 мин).""" body = {"full": True} if full or not keys else {"keys": keys} body["author"] = AUTHOR - b = _client().post("/refresh", body) - return batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" + b = await asyncio.to_thread(_client().post, "/refresh", body) + return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" @mcp.tool() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ab52dd8..6ca2fc3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,3 +1,4 @@ +import asyncio import json import re import threading @@ -23,6 +24,7 @@ def live(tmp_path, monkeypatch): monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) yield {"agent": HubClient(base, tok["agent"])} srv.shutdown() + srv.server_close() def test_offline_message(monkeypatch): @@ -48,13 +50,14 @@ def test_tools_roundtrip(live): live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) tail = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) - waited = mcp_server.batch_wait(batch_id, timeout_minutes=1) + waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1)) assert "done" in waited and "failed" in waited and "HTTP 400" in waited assert f"#{batch_id}" in mcp_server.batches(limit=5) assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) - r = mcp_server.refresh(full=True, wait=False) + r = asyncio.run(mcp_server.refresh(full=True)) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) + assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False))) assert "онлайн" in mcp_server.agent_status() assert "120" in mcp_server.settings_get("pacing") assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) @@ -98,10 +101,11 @@ def _fake_time(monkeypatch, step=1.0): clock["now"] += step return clock["now"] - def sleep(_): + async def sleep(_): clock["sleeps"] += 1 - monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic, sleep=sleep)) + monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic)) + monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) return clock @@ -109,7 +113,7 @@ def test_batch_wait_polls_until_done(monkeypatch): fake = FakeClient(["running", "running", "done"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch) - out = mcp_server.batch_wait(7, timeout_minutes=10) + out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10)) assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out assert fake.calls == 3 and clock["sleeps"] == 2 @@ -118,7 +122,7 @@ def test_batch_wait_timeout(monkeypatch): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов - out = mcp_server.batch_wait(7, timeout_minutes=1) + out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1)) assert "таймаут ожидания" in out assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 @@ -128,7 +132,7 @@ def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута - mcp_server.batch_wait(7, timeout_minutes=asked) + asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked)) # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна assert fake.calls == minutes @@ -137,7 +141,7 @@ def test_client_created_once_per_wait(monkeypatch): fake, made = FakeClient(["running", "running", "done"]), [] monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) _fake_time(monkeypatch) - mcp_server.batch_wait(7) + asyncio.run(mcp_server.batch_wait(7)) assert len(made) == 1 @@ -146,3 +150,37 @@ def test_sync_log_content(monkeypatch): "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1" + + +def test_async_tools_are_coroutines_and_registered(): + assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh) + tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} + assert {"batch_wait", "refresh"} <= set(tools) + assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False + + +def test_async_guard_offline(monkeypatch): + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE + assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE + + +def test_batch_wait_does_not_block_event_loop(monkeypatch): + """Пока идёт ожидание, другая задача event loop продолжает выполняться.""" + fake = FakeClient(["running", "running", "done"]) + monkeypatch.setattr(mcp_server, "_client", lambda: fake) + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01) + + async def scenario(): + ticks = [] + + async def ticker(): + for _ in range(5): + ticks.append(1) + await asyncio.sleep(0.005) + + out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker()) + return out, ticks + + out, ticks = asyncio.run(scenario()) + assert "done" in out and len(ticks) == 5