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
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## 2026-07-16

- Give post-deploy QA one source of truth for acceptance criteria. `Repository.acceptance_criteria`
is seeded with a baseline health check at repository creation, so an automatically created story
reaches QA without anyone filling the repository in by hand. The deploy → QA handoff resolves the
criteria and carries them on `QAMessage`; missing criteria now fail the story visibly before it
reaches TESTING (and answer 422 on admin `run-e2e`) instead of leaving a QA run that can only
error with `qa_no_acceptance_criteria`. Criteria stating only `GET <path> returns <status>` are
checked over HTTP by the QA consumer, so a health-only story completes with outcome `passed` and
no LLM. The mega now gates on the QA run the pipeline itself produced rather than its own health
request.
- Fix live server cleanup for service-template compose names that normalize project slugs with
underscores. Teardown now discovers actual `com.docker.compose.project` labels from live
containers, runs compose down for both manifest and discovered names, removes by label and
Expand Down
15 changes: 13 additions & 2 deletions docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ cannot poison-loop the reclaim.
|-------|-------|-----|-----------|----------|---------|
| `engineering:queue` | `capability-workers` | EngineeringMessage | Task Dispatcher (scheduler) | langgraph | Start development task |
| `deploy:queue` | `capability-workers` | DeployMessage | Task Dispatcher (scheduler) / PO | langgraph | Start deploy task |
| `qa:queue` | `qa-consumers` | QAMessage | Task Dispatcher (scheduler) / Admin API | langgraph (qa-worker) | Post-deploy QA testing via Claude Code on prod server |
| `qa:queue` | `qa-consumers` | QAMessage | Task Dispatcher (scheduler) / Admin API | langgraph (qa-worker) | Post-deploy QA: HTTP checks for GET-only criteria, else Claude Code on prod server |

---

Expand Down Expand Up @@ -1035,12 +1035,23 @@ class QAMessage(BaseMessage):
user_id: str
deployed_url: str
application_id: int
acceptance_criteria: str # resolved by the producer, never by the consumer
run_id: str = ""
bot_username: str | None = None
qa_attempt: int = 0
```

**Flow:** Deploy succeeds → supervisor creates QA run → publishes QAMessage → QA consumer SSHes to prod server → runs Claude Code with QA prompt → writes `QAOutcome` to `run.result`. Supervisor polls run outcome and routes: PASSED → complete story, FAILED → create fix task + redispatch to engineering, EXHAUSTED/ERROR → fail story.
**Acceptance criteria:** `Repository.acceptance_criteria` is the single source of truth for what QA tests. `POST /api/repositories/` seeds every repository with `BASELINE_ACCEPTANCE_CRITERIA` (`shared/contracts/acceptance.py`), so a story that never reached the architect still has criteria; the architect's `update_acceptance_criteria` tool extends the list as stories add functionality. Story and task criteria describe work to be done and are not what QA runs.

Producers (supervisor, admin `run-e2e`) resolve the criteria and put them on the message. Both refuse to create a QA run without them — the supervisor fails the story visibly before it reaches TESTING, and `run-e2e` answers 422 — so QA never starts a run it can only error out of.

**Health-only criteria:** criteria whose every line is a plain `- GET <path> returns <status>` are decided by the QA consumer over HTTP (`parse_health_only_criteria` → `run_health_checks`), with no SSH and no LLM. One prose line sends the whole block to Claude Code on the server instead.

`returns <status>` means the path itself answers that status, so the checks do not follow redirects: a criterion naming a redirect is checked against the redirect, and a criterion naming 200 is not satisfied by a path that redirects to a 200. Checks are retried while the service is still coming up.

The consumer parses the criteria *before* resolving anything else, and only the agent branch reads the server, its SSH key, and `bot_username`. A criteria block the deployed URL alone can answer must not fail over agent scaffolding it never uses.

**Flow:** Deploy succeeds → supervisor resolves criteria → transitions story to TESTING → creates QA run → publishes QAMessage → QA consumer runs the criteria (HTTP checks, or Claude Code on the prod server) → writes `QAOutcome` to `run.result`. Supervisor polls run outcome and routes: PASSED → complete story, FAILED → create fix task + redispatch to engineering, EXHAUSTED/ERROR → fail story.

**Lifecycle operations:** `stop` and `undeploy` actions are handled by the `deploy_lifecycle` module, which SSHes to the server and runs `docker compose stop/down` directly — skipping the full DevOps subgraph.

Expand Down
10 changes: 10 additions & 0 deletions services/api/src/routers/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,15 @@ async def run_e2e(
port = app.port_allocations[0].port if app.port_allocations else 0
deployed_url = f"http://{server.public_ip}:{port}" if port else f"http://{server.public_ip}"

# QA validates against the repository's criteria — reject before creating the
# Run, so a project without them doesn't leave a QA run that can only error.
acceptance_criteria = (repo.acceptance_criteria or "").strip()
if not acceptance_criteria:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Repository {repo.id} has no acceptance_criteria. Cannot run QA.",
)

# Create Run
run_id = f"qa-{uuid.uuid4().hex[:12]}"
run = Run(
Expand All @@ -403,6 +412,7 @@ async def run_e2e(
user_id="",
deployed_url=deployed_url,
application_id=application_id,
acceptance_criteria=acceptance_criteria,
run_id=run_id,
bot_username=repo.bot_username,
)
Expand Down
2 changes: 2 additions & 0 deletions services/api/src/routers/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
import structlog

from shared.contracts.acceptance import BASELINE_ACCEPTANCE_CRITERIA
from shared.models.project import Project
from shared.models.repository import Repository

Expand Down Expand Up @@ -54,6 +55,7 @@ async def create_repository(
role=body.role.value,
visibility=body.visibility.value,
is_managed=body.is_managed,
acceptance_criteria=BASELINE_ACCEPTANCE_CRITERIA,
created_at=now,
updated_at=now,
)
Expand Down
25 changes: 23 additions & 2 deletions services/api/tests/service/test_admin_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import pytest
from redis.asyncio import Redis

from shared.contracts.acceptance import BASELINE_ACCEPTANCE_CRITERIA

TASK_TEST_TELEGRAM_ID = 999000999
TASK_TEST_PROJECT_ID = "00000000-0000-0000-0000-000000000001"

Expand Down Expand Up @@ -226,9 +228,9 @@ async def _create_repo(client) -> str:
return resp.json()["id"]


async def _create_running_app(client, server_handle, app_status="running"):
async def _create_running_app(client, server_handle, app_status="running", repo_id=None):
"""Helper: create an application with a unique repo and a port."""
rid = await _create_repo(client)
rid = repo_id or await _create_repo(client)
svc_name = f"svc-{uuid.uuid4().hex[:6]}"
resp = await client.post(
"/api/applications/",
Expand Down Expand Up @@ -328,6 +330,9 @@ async def test_run_e2e_on_running_app(self, client, redis, server_handle):
msg = await _read_last_message(redis, "qa:queue")
assert msg["application_id"] == app_id
assert "10.0.0.1" in msg["deployed_url"]
# A repository is seeded with criteria at creation, so QA gets something
# to test without anyone filling the repository in by hand.
assert msg["acceptance_criteria"] == BASELINE_ACCEPTANCE_CRITERIA

@pytest.mark.asyncio
async def test_run_e2e_not_running_fails(self, client, server_handle):
Expand All @@ -336,6 +341,22 @@ async def test_run_e2e_not_running_fails(self, client, server_handle):
resp = await client.post(f"/api/applications/{app_id}/run-e2e")
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY

@pytest.mark.asyncio
async def test_run_e2e_without_criteria_creates_no_run(self, client, server_handle):
"""Criteria cleared → rejected before a Run exists, not a run that can only error."""
rid = await _create_repo(client)
app_id = await _create_running_app(client, server_handle, repo_id=rid)

resp = await client.patch(f"/api/repositories/{rid}", json={"acceptance_criteria": ""})
assert resp.status_code == HTTPStatus.OK

resp = await client.post(f"/api/applications/{app_id}/run-e2e", json={"actor": "test"})
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert "acceptance_criteria" in resp.text

runs = await client.get(f"/api/applications/{app_id}/runs")
assert runs.json() == []


# ---------------------------------------------------------------------------
# from-repo
Expand Down
5 changes: 0 additions & 5 deletions services/langgraph/src/clients/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,6 @@ async def get_primary_repository(self, project_id: str) -> RepositoryDTO | None:
return repo
return repos[0] if repos else None

async def get_repository(self, repo_id: str) -> RepositoryDTO:
"""Get a single repository by ID."""
resp = await self._request("GET", f"repositories/{repo_id}")
return RepositoryDTO.model_validate(resp.json())

async def update_repository(self, repo_id: str, payload: dict) -> RepositoryDTO:
"""PATCH a repository."""
resp = await self._request("PATCH", f"repositories/{repo_id}", json=payload)
Expand Down
70 changes: 67 additions & 3 deletions services/langgraph/src/consumers/_qa_runner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""QA runnerSSH to prod server, run Claude Code, parse result.
"""QA runnersHTTP checks for criteria we can decide, Claude Code for the rest.

Delegates actual testing to Claude Code CLI running on the target server.
The prompt is built from the acceptance criteria and deployment URL.
Criteria that only state GET expectations are run directly against the deployed
URL by `run_health_checks`. Anything else goes to `run_qa_on_server`, which
delegates testing to the Claude Code CLI on the target server, prompted with the
acceptance criteria and deployment URL.
"""

from __future__ import annotations
Expand All @@ -16,11 +18,16 @@
import httpx
import structlog

from shared.contracts.acceptance import HealthCriterion

from ..prompts.qa import build_qa_prompt

logger = structlog.get_logger(__name__)

QA_TIMEOUT = 1200 # 20 minutes
HEALTH_CHECK_TIMEOUT = 30
HEALTH_CHECK_ATTEMPTS = 5
HEALTH_CHECK_RETRY_DELAY = 5
SERVICE_BASE_DIR = "/opt/services"
CREDENTIALS_PATH = "$HOME/.claude/.credentials.json"
LOCAL_CREDENTIALS_PATH = "/secrets/claude-credentials.json" # mounted from host
Expand Down Expand Up @@ -90,6 +97,63 @@ def parse_qa_result(raw: str) -> QAResult:
)


async def run_health_checks(
*,
deployed_url: str,
checks: list[HealthCriterion],
) -> QAResult:
"""Run GET criteria against the deployed URL. No SSH, no LLM.

Each check is retried while the service is still coming up; a check that
never answers with its expected status fails the run.
"""
results = []
# "returns 200" means the path itself answers 200. Following redirects would
# report the destination's status instead, so a criterion naming a redirect
# could never pass and one naming 200 would pass on a redirected path.
async with httpx.AsyncClient(timeout=HEALTH_CHECK_TIMEOUT, follow_redirects=False) as client:
for check in checks:
results.append(await _run_health_check(client, deployed_url, check))

failed = [c for c in results if not c["pass"]]
passed = not failed
summary = (
f"{len(results)} GET check(s) passed against {deployed_url}"
if passed
else f"{len(failed)}/{len(results)} GET check(s) failed against {deployed_url}"
)
logger.info("qa_health_checks_done", deployed_url=deployed_url, passed=passed)
return QAResult(
passed=passed,
checks=results,
summary=summary,
report="\n".join(f"- {c['name']}: {c['detail']}" for c in results),
)


async def _run_health_check(
client: httpx.AsyncClient,
deployed_url: str,
check: HealthCriterion,
) -> dict:
"""GET one path, retrying until it answers as expected or attempts run out."""
name = f"GET {check.path} returns {check.expected_status}"
detail = "no response"
for attempt in range(HEALTH_CHECK_ATTEMPTS):
if attempt:
await asyncio.sleep(HEALTH_CHECK_RETRY_DELAY)
try:
response = await client.get(f"{deployed_url.rstrip('/')}{check.path}")
except httpx.HTTPError as e:
detail = f"request failed: {e}"
continue
if response.status_code == check.expected_status:
return {"name": name, "pass": True, "detail": f"got {response.status_code}"}
detail = f"got {response.status_code}, expected {check.expected_status}"
logger.warning("qa_health_check_failed", path=check.path, detail=detail)
return {"name": name, "pass": False, "detail": detail}


async def _ensure_claude_credentials(conn: asyncssh.SSHClientConnection) -> None:
"""Check Claude Code OAuth credentials on server, refresh if expired.

Expand Down
101 changes: 55 additions & 46 deletions services/langgraph/src/consumers/qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import structlog

from shared.contracts.acceptance import parse_health_only_criteria
from shared.contracts.dto.run import RunStatus
from shared.contracts.dto.run_result import QAFailedCheck, QARunResult
from shared.contracts.queues.qa import QAMessage, QAOutcome, QAServerInfo
Expand All @@ -19,7 +20,12 @@

from ..clients.api import api_client
from ._base import run_queue_worker, validate_queued_message
from ._qa_runner import QAResult, credential_refresh_loop, run_qa_on_server
from ._qa_runner import (
QAResult,
credential_refresh_loop,
run_health_checks,
run_qa_on_server,
)

logger = structlog.get_logger(__name__)

Expand Down Expand Up @@ -93,61 +99,64 @@ async def process_qa_job(job_data: dict, redis: RedisStreamClient) -> dict:
return {"status": "skipped", "reason": "already_inflight"}

try:
# Resolve server info
server_info = await _resolve_server_info(msg.application_id)
if not server_info:
error = f"Cannot resolve server for application {msg.application_id}"
logger.error(
"qa_server_resolve_failed",
application_id=msg.application_id,
)
await _update_run(run_id, RunStatus.FAILED, QAOutcome.ERROR, error=error)
return {"status": "error", "error": error}

# Fail-fast: if project has tg_bot module, bot_username is required
if not msg.bot_username:
project = await api_client.get_project(msg.project_id)
modules = (project.config or {}).get("modules", [])
if "tg_bot" in modules:
error = (
"Project has tg_bot module but bot_username is missing in QAMessage. "
"Deploy smoke test should have resolved it via getMe."
# The criteria travel on the message — the producer resolves them from the
# repository before it creates this run. They decide how QA runs, so parse
# them first: criteria that only state GET expectations need nothing but
# the deployed URL.
acceptance_criteria = msg.acceptance_criteria
health_checks = parse_health_only_criteria(acceptance_criteria)

# A server to SSH into and a bot to talk to are what the agent needs, not
# what the criteria ask for. Resolve them inside the agent branch only —
# an HTTP check must not fail over an SSH key it never reads.
server_info = None
if health_checks is None:
server_info = await _resolve_server_info(msg.application_id)
if not server_info:
error = f"Cannot resolve server for application {msg.application_id}"
logger.error(
"qa_server_resolve_failed",
application_id=msg.application_id,
)
logger.error("qa_bot_username_missing", story_id=story_id, modules=modules)
await _update_run(run_id, RunStatus.FAILED, QAOutcome.ERROR, error=error)
return {"status": "error", "error": error}

# Resolve acceptance criteria from repository
app = await api_client.get_application(msg.application_id)
repo = await api_client.get_repository(app.repo_id)
acceptance_criteria = repo.acceptance_criteria or ""

if not acceptance_criteria:
error = (
f"Repository {repo.id} has no acceptance_criteria. "
"Cannot run QA without regression test criteria."
)
logger.error("qa_no_acceptance_criteria", repo_id=repo.id)
await _update_run(run_id, RunStatus.FAILED, QAOutcome.ERROR, error=error)
return {"status": "error", "error": error}

# Mark run as running before starting Claude Code
# Fail-fast: if project has tg_bot module, bot_username is required
if not msg.bot_username:
project = await api_client.get_project(msg.project_id)
modules = (project.config or {}).get("modules", [])
if "tg_bot" in modules:
error = (
"Project has tg_bot module but bot_username is missing in QAMessage. "
"Deploy smoke test should have resolved it via getMe."
)
logger.error("qa_bot_username_missing", story_id=story_id, modules=modules)
await _update_run(run_id, RunStatus.FAILED, QAOutcome.ERROR, error=error)
return {"status": "error", "error": error}

# Mark run as running before starting the checks
if run_id:
await api_client.patch(
f"runs/{run_id}",
json={"status": RunStatus.RUNNING.value},
)

# Run QA on server
qa_result = await run_qa_on_server(
server_ip=server_info.server_ip,
ssh_user=server_info.ssh_user,
ssh_key=server_info.ssh_key,
project_name=server_info.project_name,
acceptance_criteria=acceptance_criteria,
deployed_url=msg.deployed_url,
bot_username=msg.bot_username,
)
if health_checks is not None:
logger.info("qa_health_only_criteria", story_id=story_id, checks=len(health_checks))
qa_result = await run_health_checks(
deployed_url=msg.deployed_url,
checks=health_checks,
)
else:
qa_result = await run_qa_on_server(
server_ip=server_info.server_ip,
ssh_user=server_info.ssh_user,
ssh_key=server_info.ssh_key,
project_name=server_info.project_name,
acceptance_criteria=acceptance_criteria,
deployed_url=msg.deployed_url,
bot_username=msg.bot_username,
)

logger.info(
"qa_result",
Expand Down
Loading
Loading