Files
roboco/tests/e2e_smoke/test_mirror_loop.py
T
a7b970a3b2 feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
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>
2026-07-26 20:01:24 +02:00

272 lines
9.5 KiB
Python

"""Scenario: the Mirror (Board Program) loop end to end.
Mirrors test_spackle_loop.py's arm -> originate -> dedup -> approve shape,
adapted for the identity collision test_periscope_loop.py's
``_seed_system_and_hom`` already solved: ``tests.e2e_smoke.arcs.
seed_company`` seeds its OWN ``head-marketing`` row under a random
``uuid4()`` (cached once per test session), which collides on the unique
``slug`` column with the FIXED foundation UUID ``MirrorEngine._originate``
hardcodes as ``assigned_to`` — so this test seeds its own system/head-
marketing/ceo agents plus its own opted-in project directly, exactly like
``test_periscope_loop.py``, rather than going through ``arcs.seed_company``/
``arcs.seed_project`` (spackle's shape works unmodified only because
``product-owner`` is absent from ``seed_company``'s roster).
Arming mirror via the settings-store key AND opting a project in opens ONE
held exploration task the delivery dispatcher's pending-claim filter skips
(board-dispatched, not delivery work); a second tick dedups (no second
task/cycle row); and approving a fake item through the real
``MirrorService`` moves the LEARN ledger's counters.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from roboco.foundation import identity as _foundation
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from tests.e2e_smoke.harness import E2EStack
ONE = 1
ZERO = 0
def _seed_system_hom_ceo_and_project(stack: E2EStack) -> str:
"""Seed ``system`` + ``head-marketing`` + ``ceo`` at their FIXED
foundation UUIDs, plus a project those two own and that opts into
mirror — ``MirrorEngine._originate`` writes ``created_by``/
``assigned_to`` straight from the static identity registry (not
``arcs.seed_company``'s random ``uuid4()`` agents; mirrors
test_periscope_loop.py's identical helper). Returns the project's slug.
"""
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
slug = f"e2e-mirror-{uuid4().hex[:8]}"
async def _run(session: AsyncSession) -> None:
for agent_uuid, agent_slug, role, team in (
(_foundation.AGENTS["system"].uuid, "system", AgentRole.SYSTEM, None),
(
_foundation.AGENTS["head-marketing"].uuid,
"head-marketing",
AgentRole.HEAD_MARKETING,
Team.BOARD,
),
(_foundation.AGENTS["ceo"].uuid, "ceo", AgentRole.CEO, None),
(
_foundation.AGENTS["main-pm"].uuid,
"main-pm",
AgentRole.MAIN_PM,
Team.MAIN_PM,
),
):
if await session.get(AgentTable, agent_uuid) is not None:
continue
session.add(
AgentTable(
id=agent_uuid,
name=agent_slug,
slug=agent_slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=agent_slug,
capabilities=[],
permissions={},
metrics={},
)
)
session.add(
ProjectTable(
id=uuid4(),
name=f"E2E {slug}",
slug=slug,
git_url=f"https://example.com/{slug}.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=_foundation.AGENTS["system"].uuid,
is_active=True,
board_programs=["mirror"],
)
)
stack.run_db(_run)
return slug
def _arm(stack: E2EStack) -> None:
"""Arm via the settings-store key — the ONLY arming path (no legacy env
flag exists for mirror)."""
from roboco.db.tables import SystemSettingTable
async def _run(session: AsyncSession) -> None:
session.add(
SystemSettingTable(key="board_program.mirror.enabled", value="true")
)
stack.run_db(_run)
def _run_due_programs(stack: E2EStack) -> list[str]:
from roboco.services.board_programs import get_board_program_engine
async def _run(session: AsyncSession) -> list[str]:
return await get_board_program_engine(session).run_due_programs()
result: list[str] = stack.run_db(_run)
return result
def _find_mirror_task(stack: E2EStack) -> dict[str, Any]:
from roboco.db.tables import TaskTable
from roboco.services.task import MIRROR_SOURCE
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
rows = (
(
await session.execute(
select(TaskTable).where(TaskTable.source == MIRROR_SOURCE)
)
)
.scalars()
.all()
)
return {
"count": len(rows),
"rows": [
{
"id": r.id,
"status": str(r.status),
"assigned_to": r.assigned_to,
"source": r.source,
"confirmed_by_human": r.confirmed_by_human,
}
for r in rows
],
}
state: dict[str, Any] = stack.run_db(_run)
return state
def _cycle_counters(stack: E2EStack) -> dict[str, Any]:
from roboco.db.tables import BoardProgramCycleTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
(
await session.execute(
select(BoardProgramCycleTable)
.where(BoardProgramCycleTable.program_key == "mirror")
.order_by(BoardProgramCycleTable.opened_at.desc())
.limit(1)
)
)
.scalars()
.first()
)
assert row is not None
return {
"items_proposed": row.items_proposed,
"items_approved": row.items_approved,
"items_rejected": row.items_rejected,
}
state: dict[str, Any] = stack.run_db(_run)
return state
def _approve_fake_item(stack: E2EStack, task_id: Any, project_slug: str) -> str:
from roboco.db.tables import TaskTable
from roboco.foundation.policy.content import markers
from roboco.services.mirror_service import get_mirror_service
from sqlalchemy import select
async def _run(session: AsyncSession) -> str:
task = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
markers.set_messaging_fixes(
task,
{
"items": [
{
"id": "item-0",
"title": "Fix README real-time sync claim",
"description": (
"README says real-time; sync is polled every 30s"
),
"acceptance_criteria": ["README wording matches behavior"],
"project_slug": project_slug,
"team": "backend",
"priority": 2,
"evidence": (
"README.md:42 says 'real-time sync'; "
"roboco/services/sync.py:88 polls every 30s"
),
"status": "proposed",
"reject_reason": None,
"materialized_task_id": None,
}
],
},
)
await session.flush()
result = await get_mirror_service(session).approve_item(
task_id, "item-0", created_by=_foundation.AGENTS["ceo"].uuid
)
assert result is not None
return result.status
status: str = stack.run_db(_run)
return status
def test_mirror_loop_originates_dedups_and_records(
e2e_stack: E2EStack,
) -> None:
stack = e2e_stack
project_slug = _seed_system_hom_ceo_and_project(stack)
_arm(stack)
opened = _run_due_programs(stack)
assert opened == ["mirror"], opened
state = _find_mirror_task(stack)
assert state["count"] == ONE, state
row = state["rows"][0]
assert row["status"] == "pending"
assert row["assigned_to"] == _foundation.AGENTS["head-marketing"].uuid
assert row["confirmed_by_human"] is False
# The dispatcher's own dev-work skip recognizes this exact task shape —
# board_mirror is board-dispatched (one-shot HoM spawn), never handed to
# the generic dev dispatch loop's give_me_work/claim path.
from roboco.runtime.orchestrator import _is_non_dev_dispatch_source
assert _is_non_dev_dispatch_source({"source": row["source"]}) is True
# Second tick: the open cycle blocks re-origination — no second task.
opened_again = _run_due_programs(stack)
assert opened_again == [], opened_again
state_after = _find_mirror_task(stack)
assert state_after["count"] == ONE, state_after
# Approve a fake item on the open cycle through the real MirrorService —
# the LEARN ledger's counters move.
status = _approve_fake_item(stack, row["id"], project_slug)
assert status == "approved"
counters = _cycle_counters(stack)
assert counters["items_proposed"] == ONE
assert counters["items_approved"] == ONE
assert counters["items_rejected"] == ZERO