Files
roboco/tests/e2e_smoke/harness.py
T
d1cf6ecbf3 Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 2–3 (#295)
* feat(tests): e2e scenario 2 — the PM merge chain through the PR gate

Shared arcs extracted (arcs.py: canonical-company seeding + dev/qa/doc
segments); scenario 2 seeds a root->cell->dev hierarchy mid-flight, rides
the child through the scenario-1 arc into the cell branch (real squash
via the fake GitHub), then submit_up -> claim_gate_review/pr_pass ->
dispatcher re-claim (mirrored) -> PM complete merging cell->root. This is
the exact PM->reviewer->PM turn sequence the wave-1 turn cut shortens —
the BEFORE-net. Learned seams scripted: commit-subject validator (>=20
chars), reviewer learning-note gate, pr_pass clears ownership by design.

* feat(runtime): PR-gate turn cut — assembled parents auto-submit to the reviewer

When every child of an assembled parent is terminal, the closure
dispatcher now runs the real submit_up/submit_root through the internal
API as the owning PM (_try_auto_submit) instead of spawning the PM for
that turn — the submit's substance is deterministic gate code. Any gate
refusal falls back to the classic PM closure spawn; pr_fail routing and
the PM's final merge turn are unchanged; umbrellas never auto-submit.
ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED default-on; task.auto_submitted audit
row per cut. Proven by e2e scenario 2b (real API, real gates, real git)
against scenario 2 as the before-net.

* feat(notes): structured note sections carry a written_at trace stamp

Sections are overwrite-in-place, so without a stamp there was no way to
reconstruct WHEN a dev/qa/doc/reviewer note landed (CEO reMarkable item:
trace TIMESTAMPS). apply_structured_note stamps ISO written_at beside
the model fields; the panel notes tab renders it next to each card
title (pre-stamp rows render nothing). Progress updates, commits, and
journal entries already carried timestamps — this was the one gap.

* feat(tasks): server-side task search — title, details, and id prefix

The task list's search box only matched titles client-side, and the
trimmed summary payload deliberately carries no description — so
keyword/details/id search was impossible in the browser by design.
GET /tasks/summary gains q (ILIKE over title+description, id-prefix
match, composed with team/status and the view-permission scoping);
the panel debounces the box into the summary fetch and drops the
title-only client filter that would have hidden description matches.

* feat(wave-1): trace timestamps, real task search, Secretary task edits

- apply_structured_note stamps written_at per section; the panel notes
  tab shows it (the one trace surface without a timestamp).
- GET /tasks/summary?q= searches title+description+id-prefix server-side
  (summaries carry no description by design); panel debounces into the
  fetch and drops the title-only client filter.
- Secretary control_task gains a CEO-gated edit action over the content
  allowlist, and GET /secretary/tasks?q= resolves task names to ids for
  the chat. PM-side expansion deferred per the CEO's 'not that much'.

* fix(workspace): dep-update probe scrubs the inherited venv pin

Under uv run the orchestrator's process tree carries VIRTUAL_ENV, and a
uv-based dep_update_command in the throwaway probe clone would target
that venv instead of the clone's — the same hazard _uv_subprocess_env
already guards on the install path.

* build: private per-repo uv cache — isolate from machine-wide uvx servers

Root cause of the recurring rich/pip/bandit rot, with evidence: uv cache
clean timed out on the ~/.cache/uv lock ('is another uv process
running?') — three uvx mcp-server-fetch processes (Claude Code fetch MCP,
one alive since Wednesday) share that cache and race repo syncs on it;
poisoned entries then survive venv rebuilds because rm -rf .venv never
touches the cache, and every re-link reproduces the breakage. UV_CACHE_DIR
now pins <repo>/.uv-cache (gitignored). The earlier UV_NO_SYNC
serialization stays as defense-in-depth but was not the whole story.

* feat(tests): e2e scenario 3 — pr_fail revision loop + root→CEO chain

3a: reviewer pr_fail with a concrete issue -> needs_revision ->
i_will_plan re-entry (full plan gates) -> real fix lands on the cell
branch (the unchanged-PR hard gate refuses resubmit until it does) ->
clean second pass -> merge. 3b: submit_root -> gate -> Main PM complete
escalates the root to the CEO -> the REAL approve-and-merge endpoint
squash-merges to the origin's master. Harness gains the tasks router, a
seeded CEO identity, origin_commit, and a fake GitHub whose head.sha is
recomputed live (real-GitHub semantics the unchanged gate reads). Seeds
now encode the real shape: delivery roots are team=main_pm and
planning-typed.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 21:05:50 +02:00

480 lines
17 KiB
Python

"""e2e smoke harness — in-process RoboCo stack + scripted-agent driver.
Pieces (all REAL except GitHub and the LLM):
- The API: the real v1 flow/do routers + real middleware/exception handlers,
served by uvicorn in a thread, over the ephemeral test Postgres (the app's
own lazy engine is pointed at it by patching ``settings.database_*`` and
resetting ``_DbHolder``).
- Git: a local bare origin whose path CONTAINS ``github.com/<owner>/<repo>``
— ``_parse_git_url`` extracts owner/repo from it while clone/fetch/push
run tokenless over the local protocol.
- GitHub REST: a fake ``/_github`` router mounted on the same app
(``settings.github_api_base_url`` points at it). PR state lives in memory;
merges perform REAL git merges (squash included) on the bare origin, so
downstream git logic (cherry checks, freshness, branch sync) sees reality.
- Agents: ``ScriptedAgent`` reloads the REAL ``roboco.mcp.flow_server`` /
``do_server`` modules with that agent's env (id, role, role-scoped
manifest built from the real ``role_config``) and calls the REAL tool
functions, which POST to the in-process API over loopback HTTP.
"""
from __future__ import annotations
import asyncio
import importlib
import json
import os
import socket
import subprocess
import threading
import time
from contextlib import suppress
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import pytest
import uvicorn
from cryptography.fernet import Fernet
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import JSONResponse
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
from types import ModuleType
from uuid import UUID
_OWNER = "e2e-smoke"
_REPO = "proj"
def _git(cwd: Path, *args: str) -> str:
res = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
check=True,
)
return res.stdout.strip()
# ---------------------------------------------------------------------------
# Fake GitHub REST — PR state in memory, merges as REAL git ops on the origin
# ---------------------------------------------------------------------------
@dataclass
class _FakeGitHub:
origin: Path
admin_clone: Path
prs: dict[int, dict[str, Any]] = field(default_factory=dict)
comments: list[dict[str, Any]] = field(default_factory=list)
next_number: int = 1
def create_pr(self, title: str, body: str, head: str, base: str) -> dict[str, Any]:
number = self.next_number
self.next_number += 1
pr = {
"number": number,
"html_url": f"https://github.com/{_OWNER}/{_REPO}/pull/{number}",
"title": title,
"body": body,
"state": "open",
"merged": False,
"head": {
"ref": head,
"sha": self._sha_of(head),
"repo": {"full_name": f"{_OWNER}/{_REPO}"},
},
"base": {"ref": base},
"user": {"login": "e2e-bot"},
"author_association": "MEMBER",
}
self.prs[number] = pr
return pr
def _sha_of(self, branch: str) -> str:
try:
return _git(self.origin, "rev-parse", branch)
except subprocess.CalledProcessError:
return "0" * 40
def merge_pr(self, number: int, merge_method: str) -> dict[str, Any]:
pr = self.prs[number]
head, base = pr["head"]["ref"], pr["base"]["ref"]
admin = self.admin_clone
_git(admin, "fetch", "origin", "--prune")
_git(admin, "checkout", "-B", base, f"origin/{base}")
if merge_method == "squash":
_git(admin, "merge", "--squash", f"origin/{head}")
_git(admin, "commit", "-m", f"{pr['title']} (#{number})")
else:
_git(
admin,
"merge",
"--no-ff",
"-m",
f"Merge pull request #{number} from {head}",
f"origin/{head}",
)
_git(admin, "push", "origin", base)
sha = _git(admin, "rev-parse", "HEAD")
pr["merged"] = True
pr["state"] = "closed"
return {
"merged": True,
"sha": sha,
"message": "Pull Request successfully merged",
}
def open_prs(self, head: str | None, base: str | None) -> list[dict[str, Any]]:
out = []
for pr in self.prs.values():
if pr["state"] != "open":
continue
if head and pr["head"]["ref"] != head.split(":", 1)[-1]:
continue
if base and pr["base"]["ref"] != base:
continue
out.append(pr)
return out
def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
r = APIRouter(prefix="/_github")
@r.get("/repos/{owner}/{repo}")
async def repo_caps(owner: str, repo: str) -> dict[str, Any]:
return {
"allow_squash_merge": True,
"allow_merge_commit": True,
"allow_rebase_merge": False,
}
@r.get("/repos/{owner}/{repo}/pulls/{number}")
async def get_pr(owner: str, repo: str, number: int) -> JSONResponse:
pr = gh.prs.get(number)
if pr is None:
return JSONResponse({"message": "Not Found"}, status_code=404)
# Real GitHub recomputes head.sha as the branch advances; a stale
# creation-time snapshot broke the unchanged-PR gate's semantics.
pr["head"]["sha"] = gh._sha_of(pr["head"]["ref"])
return JSONResponse(pr)
@r.get("/repos/{owner}/{repo}/pulls")
async def list_prs(
owner: str,
repo: str,
head: str | None = None,
base: str | None = None,
state: str = "open",
) -> list[dict[str, Any]]:
return gh.open_prs(head, base)
@r.post("/repos/{owner}/{repo}/pulls", status_code=201)
async def create_pr(owner: str, repo: str, request: Request) -> dict[str, Any]:
body = await request.json()
return gh.create_pr(
body["title"], body.get("body", ""), body["head"], body["base"]
)
@r.patch("/repos/{owner}/{repo}/pulls/{number}")
async def patch_pr(
owner: str, repo: str, number: int, request: Request
) -> JSONResponse:
pr = gh.prs.get(number)
if pr is None:
return JSONResponse({"message": "Not Found"}, status_code=404)
body = await request.json()
for key in ("title", "body", "state"):
if key in body:
pr[key] = body[key]
return JSONResponse(pr)
@r.put("/repos/{owner}/{repo}/pulls/{number}/merge")
async def merge_pr(
owner: str, repo: str, number: int, request: Request
) -> JSONResponse:
if number not in gh.prs:
return JSONResponse({"message": "Not Found"}, status_code=404)
body = await request.json()
try:
result = gh.merge_pr(number, body.get("merge_method", "merge"))
except subprocess.CalledProcessError as exc:
return JSONResponse(
{"message": f"Merge conflict: {exc.stderr}"}, status_code=409
)
return JSONResponse(result)
@r.post("/repos/{owner}/{repo}/pulls/{number}/requested_reviewers", status_code=201)
async def request_reviewers(
owner: str, repo: str, number: int, request: Request
) -> dict[str, Any]:
return gh.prs.get(number, {})
@r.post("/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment(
owner: str, repo: str, number: int, request: Request
) -> dict[str, Any]:
gh.comments.append({"number": number, "body": (await request.json())})
return {"id": len(gh.comments)}
@r.delete("/repos/{owner}/{repo}/git/refs/heads/{branch:path}", status_code=204)
async def delete_branch(owner: str, repo: str, branch: str) -> None:
with suppress(subprocess.CalledProcessError):
_git(gh.origin, "branch", "-D", branch)
return r
# ---------------------------------------------------------------------------
# Stack: settings patches + origin + app + uvicorn thread
# ---------------------------------------------------------------------------
@dataclass
class E2EStack:
base_url: str
root: Path
origin: Path
workspaces_root: Path
db_url: str
github: _FakeGitHub
def workspace_of(self, project_slug: str, team: str, agent_slug: str) -> Path:
return self.workspaces_root / project_slug / team / agent_slug
def run_db(self, coro_fn: Any) -> Any:
"""Run ``coro_fn(session)`` against a fresh engine/session and return."""
async def _run() -> Any:
engine = create_async_engine(self.db_url)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
async with factory() as session:
result = await coro_fn(session)
await session.commit()
return result
finally:
await engine.dispose()
return asyncio.run(_run())
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def _seed_origin(root: Path) -> Path:
"""Bare origin at a path _parse_git_url can read owner/repo from."""
origin = root / "github.com" / _OWNER / f"{_REPO}.git"
origin.parent.mkdir(parents=True)
subprocess.run(
["git", "init", "--bare", "--initial-branch=master", str(origin)],
check=True,
capture_output=True,
)
seed = root / "seed-clone"
subprocess.run(
["git", "clone", str(origin), str(seed)], check=True, capture_output=True
)
_git(seed, "config", "user.name", "roboco-e2e")
_git(seed, "config", "user.email", "e2e@roboco.local")
(seed / "README.md").write_text("# e2e smoke project\n")
_git(seed, "add", "README.md")
_git(seed, "commit", "-m", "Initial commit")
_git(seed, "push", "origin", "master")
return origin
def _make_admin_clone(root: Path, origin: Path) -> Path:
admin = root / "gh-admin-clone"
subprocess.run(
["git", "clone", str(origin), str(admin)], check=True, capture_output=True
)
_git(admin, "config", "user.name", "fake-github")
_git(admin, "config", "user.email", "merge@github.local")
return admin
def _build_app(gh: _FakeGitHub) -> FastAPI:
from roboco.api.middleware import setup_middleware
from roboco.api.routes.health import router as health_router
from roboco.api.routes.tasks import router as tasks_router
from roboco.api.routes.v1 import do as do_module
from roboco.api.routes.v1 import flow_auditor as fa
from roboco.api.routes.v1 import flow_board as fb
from roboco.api.routes.v1 import flow_cell_pm as fcp
from roboco.api.routes.v1 import flow_dev as fd
from roboco.api.routes.v1 import flow_doc as fdoc
from roboco.api.routes.v1 import flow_main_pm as fmp
from roboco.api.routes.v1 import flow_pr_reviewer as fpr
from roboco.api.routes.v1 import flow_qa as fq
app = FastAPI(title="roboco-e2e-smoke")
setup_middleware(app)
app.include_router(health_router)
for module in (fd, fq, fdoc, fcp, fmp, fb, fa, fpr):
app.include_router(module.router)
app.include_router(do_module.router)
# The REST task surface — scenario 3 drives the real CEO
# approve-and-merge endpoint (the human gate) through it.
app.include_router(tasks_router, prefix="/api/tasks")
app.include_router(_fake_github_router(gh))
return app
def build_e2e_stack(
_test_database_url: str, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[E2EStack]:
"""Generator behind the ``e2e_stack`` fixture (defined in conftest)."""
from roboco.config import settings
from roboco.db import base as db_base
mp = pytest.MonkeyPatch()
root = tmp_path_factory.mktemp("e2e")
origin = _seed_origin(root)
admin = _make_admin_clone(root, origin)
gh = _FakeGitHub(origin=origin, admin_clone=admin)
workspaces = root / "workspaces"
workspaces.mkdir()
url = make_url(_test_database_url)
mp.setattr(settings, "database_host", url.host or "localhost")
mp.setattr(settings, "database_port", url.port or 5432)
mp.setattr(settings, "database_user", url.username or "")
mp.setattr(settings, "database_password", url.password or "")
mp.setattr(settings, "database_name", url.database or "")
mp.setattr(settings, "workspaces_root", str(workspaces))
mp.setattr(settings, "workspace_auto_clone", True)
mp.setattr(settings, "encryption_key", Fernet.generate_key().decode())
# The app's lazy engine must bind to the patched settings, not a leftover.
db_base._DbHolder.engine = None
db_base._DbHolder.session_factory = None
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
mp.setattr(settings, "github_api_base_url", f"{base_url}/_github")
app = _build_app(gh)
server = uvicorn.Server(
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
import httpx
deadline = time.time() + 30
while time.time() < deadline:
try:
# Any HTTP response at all means the server thread is up.
httpx.get(f"{base_url}/health", timeout=1)
break
except httpx.HTTPError:
time.sleep(0.1)
else:
raise RuntimeError("e2e app server did not become ready")
try:
yield E2EStack(
base_url=base_url,
root=root,
origin=origin,
workspaces_root=workspaces,
db_url=_test_database_url,
github=gh,
)
finally:
server.should_exit = True
thread.join(timeout=10)
db_base._DbHolder.engine = None
db_base._DbHolder.session_factory = None
mp.undo()
# ---------------------------------------------------------------------------
# Scripted agents — the REAL MCP tool functions, per-agent module reloads
# ---------------------------------------------------------------------------
class ScriptedAgent:
"""Drives the real flow/do MCP tool functions as one seeded agent."""
def __init__(self, stack: E2EStack, agent_id: UUID, slug: str, role: str) -> None:
self.stack = stack
self.agent_id = agent_id
self.slug = slug
self.role = role
self._manifest_path = stack.root / f"manifest-{slug}.json"
self._manifest_path.write_text(json.dumps(self._manifest()))
def _manifest(self) -> dict[str, Any]:
from roboco.services.gateway.role_config import get_role_config
cfg = get_role_config(self.role)
return {
"agent_id": str(self.agent_id),
"role": self.role,
"team": "backend",
"workspace_path": str(self.stack.workspaces_root),
"flow_tools": list(cfg.flow_tools),
"do_tools": list(cfg.do_tools),
"read_tools": ["Read", "Glob", "Grep"],
"write_tools": ["Edit", "Write"] if cfg.allows_write else [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
def _module(self, name: str) -> ModuleType:
os.environ["ROBOCO_AGENT_ID"] = str(self.agent_id)
os.environ["ROBOCO_AGENT_ROLE"] = self.role
os.environ["ROBOCO_ORCHESTRATOR_URL"] = self.stack.base_url
os.environ["ROBOCO_TOOL_MANIFEST_PATH"] = str(self._manifest_path)
module = importlib.import_module(name)
if getattr(module, "AGENT_ID", None) != str(self.agent_id):
module = importlib.reload(module)
return module
def flow(self, verb: str, /, **kwargs: Any) -> dict[str, Any]:
result: dict[str, Any] = getattr(self._module("roboco.mcp.flow_server"), verb)(
**kwargs
)
return result
def do(self, tool: str, /, **kwargs: Any) -> dict[str, Any]:
result: dict[str, Any] = getattr(self._module("roboco.mcp.do_server"), tool)(
**kwargs
)
return result
def expect_error(env: dict[str, Any], kind: str, context: str) -> dict[str, Any]:
"""Assert an envelope is the EXPECTED rejection kind."""
assert env.get("error") == kind, (
f"{context}: expected rejection {kind!r}, got error={env.get('error')!r}\n"
f" full: {json.dumps(env, default=str, indent=2)[:4000]}"
)
return env
def expect_ok(env: dict[str, Any], context: str) -> dict[str, Any]:
"""Assert an envelope is a success; on failure show the whole envelope."""
assert isinstance(env, dict), f"{context}: non-dict envelope: {env!r}"
assert not env.get("error"), (
f"{context}: rejected with error={env.get('error')!r}\n"
f" message : {env.get('message')}\n"
f" remediate: {env.get('remediate')}\n"
f" missing : {env.get('missing')}\n"
f" full : {json.dumps(env, default=str, indent=2)[:4000]}"
)
return env