Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions mcp_server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,7 @@ async def cmd_check_status(args: argparse.Namespace) -> int:
async def cmd_retry_generation(args: argparse.Namespace) -> int:
"""retry-generation: retry a failed generation."""
from services.session import set_project_root, resolve_generation_id
from services.specflow_backend import call_backend_endpoint
from services.tool_helpers import check_status_safe, is_generation_in_progress
from services.retry import retry_generation_core

root = resolve_root(args.root_path)
print(f"Using project root: {root}")
Expand All @@ -271,21 +270,12 @@ async def cmd_retry_generation(args: argparse.Namespace) -> int:
print("No previous generation found. Run `specflow run-generation` to start one.")
return 0

status_data = await check_status_safe(generation_id)
if is_generation_in_progress(status_data):
print(
"ERROR: A generation is already running. Wait for it to finish before retrying.",
file=sys.stderr,
)
try:
backend_data = await retry_generation_core(generation_id)
except Exception as exc: # noqa: BLE001 - backend validates state; surface its reason
print(f"ERROR: Couldn't retry: {exc}", file=sys.stderr)
return 1

response_text = await call_backend_endpoint(
endpoint=f"/api/v1/generation-sessions/{generation_id}/retry",
method="POST",
timeout_seconds=30,
)
data = json.loads(response_text)
print(json.dumps(data, indent=2))
print(json.dumps(backend_data, indent=2))
print("\nRetry queued. Generation will resume from the last checkpoint on the same workspaces.")
return 0

Expand Down
62 changes: 16 additions & 46 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
resolve_workspace_count,
)
from services.user_response import brief_sentences, chat_json
from services.retry import retry_generation_core

BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
logger.info("MCP Server starting with Backend URL: %s", BACKEND_URL)
Expand Down Expand Up @@ -617,58 +618,27 @@ async def retry_generation(
if sp.is_absolute():
set_project_root(sp.parent)

try:
generation_id = resolve_generation_id(generation_id)
if not generation_id:
return chat_json(
"No previous generation found. Run `run_generation` to start one.",
)

status_data = await check_status_safe(generation_id)
if is_generation_in_progress(status_data):
return _rejection_chat_payload({
"error": "A generation is already running. Wait for it to finish before retrying.",
"code": PrecheckRejectionCode.GENERATION_ALREADY_RUNNING,
"generation_id": generation_id,
})

if status_data:
st = (status_data.get("status") or "").lower()
if st == GenerationStatus.PENDING and (status_data.get("error") or "").strip():
return chat_json(
brief_sentences(
f"Last run was rejected before codegen: {status_data.get('error')} "
"Fix files locally and call `run_generation` — not `retry_generation`."
),
details=status_data,
generation_id=generation_id,
)
if st == GenerationStatus.PENDING:
return chat_json(
"Session is pending but has not failed. Use `run_generation`, not `retry_generation`.",
details=status_data,
generation_id=generation_id,
)

response_text = await call_backend_endpoint(
endpoint=f"/api/v1/generation-sessions/{generation_id}/retry",
method="POST",
timeout_seconds=30,
)
backend_data = json.loads(response_text)
generation_id = resolve_generation_id(generation_id)
if not generation_id:
return chat_json(
"Retry queued. Generation will resume from the last checkpoint on the same workspaces.",
details=backend_data,
generation_id=generation_id,
status=backend_data.get("status"),
retry_count=backend_data.get("retry_count"),
"No previous generation found. Run `run_generation` to start one.",
)

except Exception as e:
try:
backend_data = await retry_generation_core(generation_id)
except Exception as e: # noqa: BLE001 - backend validates state; surface its reason
return chat_json(
"Couldn't retry. The SpecFlow server may be unreachable.",
brief_sentences(f"Couldn't retry: {e}"),
details={"error": str(e)},
generation_id=generation_id,
)
return chat_json(
"Retry queued. Generation will resume from the last checkpoint on the same workspaces.",
details=backend_data,
generation_id=generation_id,
status=backend_data.get("status"),
retry_count=backend_data.get("retry_count"),
)


def main():
Expand Down
27 changes: 27 additions & 0 deletions mcp_server/services/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Shared retry call — one definition of the retry endpoint for CLI, MCP, and TUI.

Retry eligibility is validated by the backend state machine (``reset_for_retry``
accepts FAILED or stuck-PENDING); there is no client-side status pre-check to
drift from it. Callers POST here and render success (the returned dict) vs the
backend's error message (``str(exc)``) in their own style — mirroring the cancel
flow, which likewise defers to the backend.
"""
from __future__ import annotations

import json

from services.specflow_backend import call_backend_endpoint


async def retry_generation_core(generation_id: str) -> dict:
"""POST a retry for an already-resolved id; return the parsed backend response.

Raises on any failure — the backend validates state and returns the reason,
so callers surface ``str(exc)`` to the user.
"""
text = await call_backend_endpoint(
endpoint=f"/api/v1/generation-sessions/{generation_id}/retry",
method="POST",
timeout_seconds=30,
)
return json.loads(text)
39 changes: 25 additions & 14 deletions mcp_server/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,40 +285,51 @@ async def test_calls_retry_endpoint(self, tmp_project):

args = SimpleNamespace(root_path=str(tmp_project), command="retry-generation", generation_id=None)

# check_status_safe returns failed → proceed to retry POST
# POST succeeds → exit 0. Patched at services.retry.* where the helper binds it.
with patch(
"services.tool_helpers.check_status_safe",
new_callable=AsyncMock,
) as mock_status, patch(
"services.specflow_backend.call_backend_endpoint",
"services.retry.call_backend_endpoint",
new_callable=AsyncMock,
) as mock_ep:
mock_status.return_value = {"status": "failed"}
mock_ep.return_value = json.dumps({"status": "running", "retry_count": 1})
mock_ep.return_value = json.dumps({"status": "pending", "retry_count": 1})
code = await cmd_retry_generation(args)

assert code == 0
# Exactly one backend call: the retry POST
assert mock_ep.call_count == 1
assert mock_status.call_count == 1
assert mock_ep.call_count == 1 # single POST; backend validates state

@pytest.mark.asyncio
async def test_blocks_when_already_running(self, tmp_project, capsys):
async def test_backend_rejection_returns_one(self, tmp_project):
from cli import cmd_retry_generation
from services.session import write_session
write_session("gen-running", tmp_project)

args = SimpleNamespace(root_path=str(tmp_project), command="retry-generation", generation_id=None)

# Wrong-state / unreachable both raise from the helper → exit 1.
with patch(
"services.tool_helpers.check_status_safe",
"services.retry.call_backend_endpoint",
new_callable=AsyncMock,
) as mock_cs:
mock_cs.return_value = {"status": "running"}
) as mock_ep:
mock_ep.side_effect = Exception("Backend returned HTTP 400: Cannot retry in 'running' state")
code = await cmd_retry_generation(args)

assert code == 1

@pytest.mark.asyncio
async def test_no_session_returns_zero_without_backend_call(self, tmp_project):
from cli import cmd_retry_generation

# No write_session → resolve_generation_id returns None.
args = SimpleNamespace(root_path=str(tmp_project), command="retry-generation", generation_id=None)

with patch(
"services.retry.call_backend_endpoint",
new_callable=AsyncMock,
) as mock_ep:
code = await cmd_retry_generation(args)

assert code == 0
assert mock_ep.call_count == 0 # helper never called


# ---------------------------------------------------------------------------
# cmd_download_outputs
Expand Down
33 changes: 33 additions & 0 deletions mcp_server/tests/test_retry_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Tests for the shared retry helper (services/retry.py).

The helper POSTs the retry and returns the parsed backend dict, raising on
failure (the backend validates eligibility). Patch ``call_backend_endpoint`` at
``services.retry.*`` where the helper binds it.
"""
import json
from unittest.mock import AsyncMock, patch

import pytest

from services.retry import retry_generation_core

GID = "est-abc123"


@pytest.mark.asyncio
async def test_returns_backend_dict_on_success():
post = AsyncMock(return_value=json.dumps({"status": "pending", "retry_count": 1}))
with patch("services.retry.call_backend_endpoint", new=post):
data = await retry_generation_core(GID)
assert data == {"status": "pending", "retry_count": 1}
assert post.await_count == 1
assert GID in post.await_args.kwargs["endpoint"]
assert post.await_args.kwargs["method"] == "POST"


@pytest.mark.asyncio
async def test_raises_on_backend_failure():
post = AsyncMock(side_effect=Exception("Backend returned HTTP 400: Cannot retry in 'running' state"))
with patch("services.retry.call_backend_endpoint", new=post):
with pytest.raises(Exception, match="running"):
await retry_generation_core(GID)
64 changes: 64 additions & 0 deletions mcp_server/tests/test_server_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tests for the MCP ``retry_generation`` tool after deferring to the backend.

The tool resolves the id, POSTs via the shared helper, and renders success vs
the backend's error message. `ctx` is only consumed by
`apply_project_root_from_context`, which we stub. The POST is patched at
`services.retry.*` where the helper binds it.
"""
import json
from unittest.mock import AsyncMock, patch

import pytest

import server

GID = "est-abc123"


async def _run(*, resolved=GID, post=None):
"""Invoke retry_generation with IO stubbed; return (payload dict, post mock)."""
post_mock = (
AsyncMock(side_effect=post) if isinstance(post, Exception)
else AsyncMock(return_value=post)
)
with (
patch("server.apply_project_root_from_context", new=AsyncMock()),
patch("server.resolve_generation_id", return_value=resolved),
patch("services.retry.call_backend_endpoint", new=post_mock),
):
payload = await server.retry_generation(generation_id=None, ctx=object())
return json.loads(payload), post_mock


@pytest.mark.asyncio
async def test_no_session():
with (
patch("server.apply_project_root_from_context", new=AsyncMock()),
patch("server.resolve_generation_id", return_value=None),
):
payload = await server.retry_generation(generation_id=None, ctx=object())
data = json.loads(payload)
assert "No previous generation found" in data["message"]


@pytest.mark.asyncio
async def test_queued():
data, post = await _run(post=json.dumps({"status": "pending", "retry_count": 1}))
assert "retry queued" in data["message"].lower()
assert data["status"] == "pending"
assert data["retry_count"] == 1
assert data["details"] == {"status": "pending", "retry_count": 1}
assert data["generation_id"] == GID
assert post.await_count == 1


@pytest.mark.asyncio
async def test_backend_rejection_or_error_is_surfaced():
# Covers wrong-state rejections (HTTP 400) and unreachable backend alike —
# both raise from the helper and render the backend's reason.
data, post = await _run(
post=Exception("Backend returned HTTP 400: Cannot retry in 'running' state")
)
assert "couldn't retry" in data["message"].lower()
assert "running" in data["details"]["error"]
assert data["generation_id"] == GID
12 changes: 2 additions & 10 deletions mcp_server/tests/test_tui_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,17 @@
The wrappers must delegate to the existing cli.cmd_* handlers (single source of
truth for guards/precheck/backend calls), passing the namespace fields each
handler reads. We patch the handlers and assert delegation + key arguments.
(Retry no longer goes through here — it calls the retry core directly; see
test_tui_app.TestDashboardActionFlows.)
"""

from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from tui import actions


@pytest.mark.asyncio
async def test_do_retry_delegates_with_root():
with patch("cli.cmd_retry_generation", new=AsyncMock(return_value=0)) as m:
rc = await actions.do_retry(Path("/proj"))
assert rc == 0
ns = m.await_args.args[0]
assert ns.root_path == "/proj"


@pytest.mark.asyncio
async def test_do_clear_set_forces_yes():
with patch("cli.cmd_clear_workspace", new=AsyncMock(return_value=0)) as m:
Expand Down
Loading