mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Two coupled gaps in the Board Program output path. Approved items were created unowned and in BACKLOG. Nothing dispatches BACKLOG, and once activated a cell PM claimed the parentless task as a root, where _cell_pm_complete resolves its merge target through resolve_parent_branch — which for a parentless task falls through to the project head rung. The result was a cell branch merging straight into the trunk, bypassing the Main-PM root, the root->master PR and the CEO gate (live: PRs #703 and #704 both targeted slave directly). All eight materializers now create a PENDING, main-pm-assigned root with team=Team.MAIN_PM, matching what approve_and_start does for an intake draft. The team is load-bearing, not cosmetic: _next_hint_pr_fail, _deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer label all key on it, and a cell-teamed root drops the 'do NOT re-submit the root' steer that exists because of PR #138's infinite pr_fail loop. The item's own cell survives as a delegation hint in the description, which is what the Main PM's briefing renders. Periscope, Sentinel and Coroner produced artifacts with no way to act on them — three panel surfaces carried explicit 'no approve/reject UI' comments while each item already held a machine-readable suggested action. They now have per-item approve and dismiss, modelled on the roadmap queue: idempotent per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check still fires, and every decision recorded through record_decision so it reaches the next cycle's prompt. Approving materializes through the same corrected Main-PM-owned path. Target project resolves to each engine's own existing anchor — RoboCo's project for Periscope and Sentinel, the incident's project for Coroner — and fails with a clean invalid_state naming what is unresolvable rather than guessing at a repo. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Spackle (Board Program) engine route coverage — CEO-only list/approve/
|
|
reject. Mirrors test_pest_control_routes.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from roboco.api.deps import get_agent_context, get_db
|
|
from roboco.api.routes.spackle import router as spackle_router
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.foundation import identity as _foundation
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
|
|
from roboco.models.permissions import AgentContext
|
|
from roboco.services.task import SPACKLE_SOURCE
|
|
from sqlalchemy import update
|
|
|
|
_SEED_GIT_URL = "https://example.com/backend-svc-spackle.git"
|
|
|
|
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
|
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name=slug,
|
|
slug=f"{slug}-{uuid4().hex[:6]}",
|
|
role=role,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
session.add(agent)
|
|
await session.flush()
|
|
return agent
|
|
|
|
|
|
async def _seed_ceo(session: AsyncSession) -> None:
|
|
"""The CEO row matching ``CEO_UUID`` — approving an item stamps this id as
|
|
the materialized task's ``created_by``, which has an FK to ``agents``."""
|
|
if await session.get(AgentTable, CEO_UUID) is not None:
|
|
return
|
|
session.add(
|
|
AgentTable(
|
|
id=CEO_UUID,
|
|
name="ceo",
|
|
slug=f"ceo-{uuid4().hex[:6]}",
|
|
role=AgentRole.CEO,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
async def _seed_main_pm(session: AsyncSession) -> None:
|
|
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
|
assigns this id to the materialized task (an FK to ``agents``). The
|
|
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
|
other seed helpers here use): ``TaskService.approve_and_start`` and
|
|
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
|
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
|
here would permanently squat that id with an unresolvable row for every
|
|
other test in the shared suite run."""
|
|
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
|
return
|
|
session.add(
|
|
AgentTable(
|
|
id=MAIN_PM_UUID,
|
|
name="main-pm",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
|
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
|
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
|
await _seed_ceo(session)
|
|
await _seed_main_pm(session)
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Backend Service",
|
|
slug=f"backend-svc-{uuid4().hex[:6]}",
|
|
git_url=_SEED_GIT_URL,
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=system.id,
|
|
board_programs=["spackle"],
|
|
)
|
|
session.add(project)
|
|
await session.flush()
|
|
task = TaskTable(
|
|
id=uuid4(),
|
|
title="Spackle exploration cycle",
|
|
description="Audit half-shipped surface area and propose a gap-fill audit.",
|
|
acceptance_criteria=["propose_gap_fill() called once"],
|
|
status=TaskStatus.PENDING,
|
|
priority=2,
|
|
task_type=TaskType.ADMINISTRATIVE,
|
|
nature=TaskNature.NON_TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
created_by=system.id,
|
|
assigned_to=po.id,
|
|
team=Team.BOARD,
|
|
source=SPACKLE_SOURCE,
|
|
confirmed_by_human=False,
|
|
)
|
|
session.add(task)
|
|
await session.flush()
|
|
markers.set_gap_fill(
|
|
task,
|
|
{
|
|
"items": [
|
|
{
|
|
"id": "item-0",
|
|
"title": "Add panel surface for /api/widgets",
|
|
"description": "The route exists but no panel page calls it",
|
|
"acceptance_criteria": ["panel page lists widgets"],
|
|
"project_slug": project.slug,
|
|
"team": "backend",
|
|
"priority": 2,
|
|
"evidence": (
|
|
"roboco/api/routes/widgets.py:12 defines GET /widgets; "
|
|
"no panel/src page calls it"
|
|
),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
await session.flush()
|
|
return task, project
|
|
|
|
|
|
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
|
|
app = FastAPI()
|
|
app.include_router(spackle_router, prefix="/api/spackle")
|
|
|
|
async def _override_db() -> AsyncIterator[AsyncSession]:
|
|
yield db_session
|
|
|
|
async def _override_agent() -> AgentContext:
|
|
return AgentContext(agent_id=agent_id, role=role, team=None)
|
|
|
|
app.dependency_overrides[get_db] = _override_db
|
|
app.dependency_overrides[get_agent_context] = _override_agent
|
|
return app
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
|
"""CEO-authed client. Approving an item materializes a new task stamping
|
|
``agent.agent_id`` as ``created_by`` (an FK to ``agents``), so this must be
|
|
a real seeded row — ``CEO_UUID`` — not an arbitrary ``uuid4()``."""
|
|
await _seed_ceo(db_session)
|
|
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield client
|
|
app.dependency_overrides.clear()
|
|
# The approve/reject routes commit explicitly (write-route convention),
|
|
# so a leftover ``board_programs=["spackle"]`` opt-in from an
|
|
# approve/reject test here would otherwise outlive this test in the
|
|
# shared, cross-test-persistent DB and poison every later real-DB
|
|
# spackle unit test that asserts an exact opted-in-project set (e.g.
|
|
# "no project opted in"). Mirrors test_pest_control_routes.py's
|
|
# identical cleanup. Reset, not delete — a materialized BACKLOG task
|
|
# may still hold this project's id as an FK. Unconditional — a no-op
|
|
# for the tests here that never wrote anything.
|
|
await db_session.execute(
|
|
update(ProjectTable)
|
|
.where(ProjectTable.git_url == _SEED_GIT_URL)
|
|
.values(board_programs=None)
|
|
)
|
|
await db_session.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_cycles_returns_authored_cycle(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
task, _project = await _seed_cycle(db_session)
|
|
resp = await ceo_client.get("/api/spackle/cycles")
|
|
assert resp.status_code == HTTPStatus.OK
|
|
body = resp.json()
|
|
assert len(body) == 1
|
|
assert body[0]["task_id"] == str(task.id)
|
|
assert len(body[0]["items"]) == 1
|
|
assert body[0]["items"][0]["evidence"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_item_materializes_main_pm_owned_task(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
|
|
task, _project = await _seed_cycle(db_session)
|
|
resp = await ceo_client.post(f"/api/spackle/cycles/{task.id}/items/item-0/approve")
|
|
assert resp.status_code == HTTPStatus.OK
|
|
body = resp.json()
|
|
assert body["status"] == "approved"
|
|
assert body["materialized_task_id"] is not None
|
|
|
|
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
|
assert materialized is not None
|
|
assert materialized.status == TaskStatus.PENDING
|
|
assert materialized.assigned_to == MAIN_PM_UUID
|
|
assert materialized.parent_task_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_unknown_item_is_404(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
task, _project = await _seed_cycle(db_session)
|
|
resp = await ceo_client.post(
|
|
f"/api/spackle/cycles/{task.id}/items/no-such-item/approve"
|
|
)
|
|
assert resp.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_item_records_reason(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
task, _project = await _seed_cycle(db_session)
|
|
resp = await ceo_client.post(
|
|
f"/api/spackle/cycles/{task.id}/items/item-0/reject",
|
|
json={"reason": "already tracked elsewhere"},
|
|
)
|
|
assert resp.status_code == HTTPStatus.OK
|
|
body = resp.json()
|
|
assert body["status"] == "rejected"
|
|
|
|
refreshed = await db_session.get(TaskTable, task.id)
|
|
assert refreshed is not None
|
|
payload = markers.get_gap_fill(refreshed)
|
|
assert payload is not None
|
|
assert payload["items"][0]["reject_reason"] == "already tracked elsewhere"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
|
await _seed_cycle(db_session)
|
|
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
list_resp = await client.get("/api/spackle/cycles")
|
|
assert list_resp.status_code == HTTPStatus.FORBIDDEN
|
|
app.dependency_overrides.clear()
|