Files
roboco/tests/e2e_smoke/arcs.py
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

737 lines
25 KiB
Python

"""Reusable scripted-agent arcs + seeding for the e2e smoke scenarios.
The company is seeded ONCE per stack session (canonical slugs — the A2A
permission model resolves roles/teams from the static ``agents_config``
registry, so slugs must match it). Projects and tasks are seeded per test
with unique slugs so scenarios never collide on constraints.
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from tests.e2e_smoke.harness import E2EStack, ScriptedAgent, expect_error, expect_ok
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
class Company:
"""Seeded canonical agents (ids) — one per stack session."""
dev_id: Any
qa_id: Any
doc_id: Any
cell_pm_id: Any
main_pm_id: Any
pr_reviewer_id: Any
ceo_id: Any
hom_id: Any # Head of Marketing — Board role
_COMPANY_CACHE: dict[str, Company] = {}
def seed_company(stack: E2EStack) -> Company:
"""Seed the canonical agents once; return their ids on every call."""
if "company" in _COMPANY_CACHE:
return _COMPANY_CACHE["company"]
from roboco.db.tables import AgentTable
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, Team
out = Company()
async def _run(session: AsyncSession) -> None:
def agent(
slug: str, role: AgentRole, team: Team | None, *, agent_id: Any = None
) -> AgentTable:
row = AgentTable(
id=agent_id or uuid4(),
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
session.add(row)
return row
dev = agent("be-dev-1", AgentRole.DEVELOPER, Team.BACKEND)
qa = agent("be-qa", AgentRole.QA, Team.BACKEND)
doc = agent("be-doc", AgentRole.DOCUMENTER, Team.BACKEND)
cell_pm = agent("be-pm", AgentRole.CELL_PM, Team.BACKEND)
# The canonical fixed UUID, not a random one: RoadmapService et al.'s
# per-item materialize (and the MegaTask main_pm-route batch confirm)
# hardcode AGENT_UUIDS["main-pm"] as the owning assignee, an FK to a
# real agents row — the same identity production seeding uses.
main_pm = agent(
"main-pm",
AgentRole.MAIN_PM,
None,
agent_id=_foundation.AGENTS["main-pm"].uuid,
)
reviewer = agent("pr-reviewer-1", AgentRole.PR_REVIEWER, None)
ceo = agent("ceo", AgentRole.CEO, None)
hom = agent("head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD)
await session.flush()
out.ceo_id = ceo.id
out.dev_id = dev.id
out.qa_id = qa.id
out.doc_id = doc.id
out.cell_pm_id = cell_pm.id
out.main_pm_id = main_pm.id
out.pr_reviewer_id = reviewer.id
out.hom_id = hom.id
stack.run_db(_run)
_COMPANY_CACHE["company"] = out
return out
def seed_project(stack: E2EStack, company: Company) -> tuple[Any, str]:
"""Seed a project rooted at the shared bare origin; unique slug per test."""
from roboco.db.tables import ProjectTable
from roboco.models import Team
from roboco.utils.crypto import encrypt_token
slug = f"e2e-proj-{uuid4().hex[:6]}"
holder: dict[str, Any] = {}
async def _run(session: AsyncSession) -> None:
project = ProjectTable(
id=uuid4(),
name=f"E2E {slug}",
slug=slug,
git_url=str(stack.origin),
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=company.main_pm_id,
is_active=True,
git_token_encrypted=encrypt_token("e2e-dummy-token"),
)
session.add(project)
await session.flush()
holder["id"] = project.id
stack.run_db(_run)
return holder["id"], slug
def seed_task(stack: E2EStack, **overrides: Any) -> Any:
"""Seed one task row; caller passes the fields that matter."""
from roboco.db.tables import TaskTable
from roboco.models import Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
fields: dict[str, Any] = {
"id": uuid4(),
"acceptance_criteria": ["done"],
"status": TaskStatus.PENDING,
"priority": 2,
"task_type": TaskType.CODE,
"nature": TaskNature.TECHNICAL,
"estimated_complexity": Complexity.LOW,
"team": Team.BACKEND,
"confirmed_by_human": True,
}
fields.update(overrides)
async def _run(session: AsyncSession) -> None:
session.add(TaskTable(**fields))
stack.run_db(_run)
return fields["id"]
def task_state(stack: E2EStack, task_id: Any) -> dict[str, Any]:
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return {
"status": str(row.status),
"branch_name": row.branch_name,
"pr_number": row.pr_number,
"docs_complete": row.docs_complete,
"assigned_to": row.assigned_to,
}
state: dict[str, Any] = stack.run_db(_run)
return state
def open_finding_ids(stack: E2EStack, task_id: Any) -> list[str]:
"""Open revision-findings ledger row ids for a task — the ids a scripted
PM/dev must resolve before FINDINGS_ADDRESSED lets it resubmit."""
from roboco.db.tables import TaskReviewFindingTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> list[str]:
rows = (
(
await session.execute(
select(TaskReviewFindingTable.id).where(
TaskReviewFindingTable.task_id == task_id,
TaskReviewFindingTable.status == "open",
)
)
)
.scalars()
.all()
)
return [str(row) for row in rows]
ids: list[str] = stack.run_db(_run)
return ids
def wait_for_status(
stack: E2EStack,
task_id: Any,
expected: str,
*,
timeout: float = 10.0,
interval: float = 0.25,
) -> dict[str, Any]:
"""Poll ``task_state`` until ``status == expected`` or timeout.
The e2e stack commits on the uvicorn thread's event loop and reads via a
separate loop (``run_db`` -> ``asyncio.run`` with a fresh engine). A
terminal single point-read can race a still-draining completion hook on a
contended runner and observe a pre-terminal state; the bounded poll
absorbs that transient. A genuine state bug still surfaces: the timeout
branch asserts against the last-read state, so a real regression fails
loudly with the actual (non-terminal) state instead of a misleading
one-shot mismatch.
"""
deadline = time.monotonic() + timeout
last: dict[str, Any] = {}
while True:
last = task_state(stack, task_id)
if last["status"] == expected:
return last
if time.monotonic() >= deadline:
assert last["status"] == expected, last
time.sleep(interval)
def dispatcher_assign(stack: E2EStack, task_id: Any, agent_id: Any) -> None:
"""Mirror the dispatcher's claim-for-PM lane (_dispatch_pm_review_work):
pr_pass clears ownership by design and the orchestrator re-claims the
task for the owning PM before spawning it."""
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> None:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
row.assigned_to = agent_id
row.active_claimant_id = agent_id
stack.run_db(_run)
def set_branch_name(stack: E2EStack, task_id: Any, branch_name: str) -> None:
"""Directly set a task's ``branch_name`` — a data field, not a lifecycle
transition (mirrors ``dispatcher_assign``'s style: standing in for a
system-side effect the real claim path would otherwise perform)."""
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> None:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
row.branch_name = branch_name
stack.run_db(_run)
def wire_dependency(stack: E2EStack, dependent_id: Any, depends_on_id: Any) -> None:
"""Wire a real dependency edge the same way production sequencing does
(``TaskService.add_dependency``) — not a direct status write."""
from roboco.services.task import get_task_service
async def _run(session: AsyncSession) -> None:
await get_task_service(session).add_dependency(dependent_id, depends_on_id)
stack.run_db(_run)
def origin_branch(stack: E2EStack, name: str, start: str = "master") -> None:
"""Create + push a branch in the shared origin via the admin clone."""
from tests.e2e_smoke.harness import _git
admin = stack.github.admin_clone
_git(admin, "fetch", "origin", "--prune")
_git(admin, "checkout", "-B", name, f"origin/{start}")
_git(admin, "push", "origin", name)
def origin_commit(
stack: E2EStack, branch: str, path: str, content: str, message: str
) -> None:
"""Land a commit on a branch in the origin via the admin clone —
stands in for dev work advancing a branch between scripted turns."""
from tests.e2e_smoke.harness import _git
admin = stack.github.admin_clone
_git(admin, "fetch", "origin", "--prune")
_git(admin, "checkout", "-B", branch, f"origin/{branch}")
(admin / path).write_text(content)
_git(admin, "add", path)
_git(admin, "commit", "-m", message)
_git(admin, "push", "origin", branch)
def origin_file(stack: E2EStack, branch: str, path: str) -> str | None:
"""Read a file's content at a branch tip in the origin, or None."""
import subprocess
from tests.e2e_smoke.harness import _git
try:
return _git(stack.github.origin, "show", f"{branch}:{path}")
except subprocess.CalledProcessError:
return None
# ---------------------------------------------------------------------------
# Arcs — each drives one role through one lifecycle segment, gates and all
# ---------------------------------------------------------------------------
def dev_arc(
stack: E2EStack,
company: Company,
project_slug: str,
task_id: Any,
*,
work: tuple[str, str] = ("greeting.txt", "Hello from the e2e smoke agent!\n"),
) -> None:
"""PENDING (pre-assigned) → awaiting_qa: claim, work, commit, PR, submit."""
filename, content = work
tid = str(task_id)
dev = ScriptedAgent(stack, company.dev_id, "be-dev-1", "developer")
env = expect_ok(dev.flow("give_me_work"), "dev give_me_work")
assert env.get("task_id") == tid, f"expected task {tid}, got: {env}"
def _claim() -> dict[str, Any]:
return dev.flow(
"i_will_work_on",
task_id=tid,
plan=(
f"Create {filename} at the repository root with the required "
"content, commit it on the task branch with the task-prefixed "
"message, push the branch to origin, open the pull request "
"against the base branch, and self-verify every acceptance "
"criterion by re-reading the committed file content."
),
steps=[
{
"title": f"Write {filename}",
"description": (
f"Create {filename} at the repo root containing the "
"required content for the acceptance criteria."
),
},
{
"title": "Commit and push",
"description": (
"Commit the new file on the task branch with a "
"task-prefixed message and push it to origin."
),
},
{
"title": "Open PR and self-verify",
"description": (
"Open the pull request against the base branch and "
"re-read the file to confirm the criteria hold."
),
},
],
technical_considerations=["Plain text file; no build impact."],
risks=[
{
"risk": "None of substance — purely additive file.",
"mitigation": "Self-verify the file content before submit.",
}
],
open_questions=[],
)
# Real choreography: the composed claim succeeds and stays; the
# post-claim tracing gate demands the claim-time note; the retry
# short-circuits as re-entry.
expect_error(_claim(), "tracing_gap", "dev first i_will_work_on")
expect_ok(
dev.do(
"note",
scope="note",
task_id=tid,
text=(
"Initial assessment: a single additive text file at the repo "
"root satisfies the acceptance criteria; no existing code is "
"touched, so risk is minimal and the plan is a three-step "
"write/commit/PR sequence."
),
),
"dev note at claim",
)
expect_ok(_claim(), "dev i_will_work_on retry")
workspace = stack.workspace_of(project_slug, "backend", "be-dev-1")
workdir = workspace / ".worktrees" / tid[:8]
assert workdir.is_dir(), f"per-task worktree missing at {workdir}"
(workdir / filename).write_text(content)
expect_ok(
dev.do(
"commit",
message=f"feat: add {filename} with the required greeting content",
files=[filename],
),
"dev commit",
)
expect_ok(
dev.do(
"note",
scope="note",
task_id=tid,
text=(
f"{filename} written and committed on the task branch; "
"opening the PR next, then self-verifying the acceptance "
"criteria before submit."
),
),
"dev progress note",
)
env = expect_ok(dev.flow("open_pr", task_id=tid), "dev open_pr")
assert task_state(stack, task_id)["pr_number"], f"no PR recorded: {env}"
criteria = _criteria_text(stack, task_id)
expect_ok(
dev.do(
"note",
scope="decision",
task_id=tid,
text=(
"Verified every acceptance criterion on the branch: "
+ criteria
+ " — all hold against the committed content. Decision: no "
"further changes needed; the file is self-contained."
),
),
"dev during-work decision note",
)
expect_ok(
dev.do(
"note",
text="Handoff summary below (section carries the content).",
scope="handoff",
task_id=tid,
section={
"summary": (
f"Built {filename} at the repo root on the task branch; "
"PR is open against the base branch; single additive "
"commit, no risks beyond trivial content review."
)
},
),
"dev handoff section",
)
expect_ok(
dev.do(
"note",
scope="reflect",
task_id=tid,
text=(
"Reflection: implemented the task exactly per plan — wrote "
"the file, committed on the task branch, opened the PR, and "
"self-verified the acceptance criteria against the committed "
"content."
),
),
"dev reflect note",
)
expect_ok(dev.flow("i_am_done", task_id=tid), "dev i_am_done")
assert task_state(stack, task_id)["status"] == "awaiting_qa"
def _criteria_text(stack: E2EStack, task_id: Any) -> str:
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> list[str]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return list(row.acceptance_criteria or [])
crits: list[str] = stack.run_db(_run)
return "; ".join(f'"{c}"' for c in crits)
def qa_arc(stack: E2EStack, company: Company, task_id: Any) -> None:
"""awaiting_qa → awaiting_documentation."""
tid = str(task_id)
qa = ScriptedAgent(stack, company.qa_id, "be-qa", "qa")
expect_ok(qa.flow("claim_review", task_id=tid), "qa claim_review")
expect_ok(
qa.do(
"note",
scope="learning",
task_id=tid,
text=(
"Review learning: the change is a single additive file; diff "
"inspection on the PR confirms the acceptance criteria with "
"no side effects on existing files."
),
),
"qa learning note",
)
async def _crits(session: AsyncSession) -> list[str]:
from roboco.db.tables import TaskTable
from sqlalchemy import select
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return list(row.acceptance_criteria or [])
criteria: list[str] = stack.run_db(_crits)
expect_ok(
qa.flow(
"pass_review",
task_id=tid,
notes=(
"Verified the PR diff on the origin: the committed change "
"satisfies every acceptance criterion; no regressions in the "
"diff, and the branch contains exactly the described commit."
),
ac_verdicts=[
f"{c} — verified against the PR diff on the origin." for c in criteria
],
criteria_verified=[
{
"criterion": c,
"evidence": "verified against the PR diff on the origin",
}
for c in criteria
],
),
"qa pass_review",
)
assert task_state(stack, task_id)["status"] == "awaiting_documentation"
def doc_arc(stack: E2EStack, company: Company, task_id: Any, *, filename: str) -> None:
"""awaiting_documentation → awaiting_pm_review."""
tid = str(task_id)
doc = ScriptedAgent(stack, company.doc_id, "be-doc", "documenter")
expect_ok(doc.flow("claim_doc_task", task_id=tid), "doc claim_doc_task")
expect_ok(
doc.flow(
"i_documented",
task_id=tid,
files=[filename],
notes=(
f"Documented the change: {filename} carries the user-facing "
"content; no API surface changed, README untouched by design."
),
),
"doc i_documented",
)
state = task_state(stack, task_id)
assert state["status"] == "awaiting_pm_review", state
assert state["docs_complete"] is True, state
def seed_hierarchy(
stack: E2EStack, company: Company, project_id: Any
) -> dict[str, Any]:
"""Root (Main-PM) → cell (cell-PM) → dev child, seeded mid-flight.
Branch names follow the real convention (the task-short-id chain); the
PM planning/delegation lane is a later scenario's subject.
"""
from roboco.models import Team
from roboco.models.base import TaskStatus, TaskType
root_id = uuid4()
cell_id = uuid4()
root_branch = f"feature/backend/{str(root_id)[:8]}"
cell_branch = f"{root_branch}--{str(cell_id)[:8]}"
origin_branch(stack, root_branch, start="master")
origin_branch(stack, cell_branch, start=root_branch)
seed_task(
stack,
id=root_id,
title="Delivery root: greeting program",
description=(
"Root coordination task assembling the greeting feature across "
"the backend cell for the smoke harness merge-chain scenarios."
),
acceptance_criteria=["the greeting feature lands on the root branch"],
task_type=TaskType.PLANNING,
# A delivery root belongs to the Main PM's lane — team routing
# (closure, revision, reassignment) keys on this.
team=Team.MAIN_PM,
project_id=project_id,
created_by=company.main_pm_id,
assigned_to=company.main_pm_id,
status=TaskStatus.IN_PROGRESS,
branch_name=root_branch,
active_claimant_id=company.main_pm_id,
)
seed_task(
stack,
id=cell_id,
title="Backend slice: greeting file",
description=(
"Cell task assembling the backend slice of the greeting feature; "
"one dev leaf writes the file, the cell PM assembles and submits."
),
acceptance_criteria=["hello.txt exists at the repo root"],
task_type=TaskType.PLANNING,
project_id=project_id,
created_by=company.main_pm_id,
assigned_to=company.cell_pm_id,
parent_task_id=root_id,
status=TaskStatus.IN_PROGRESS,
branch_name=cell_branch,
active_claimant_id=company.cell_pm_id,
)
child_id = seed_task(
stack,
title="Write hello.txt",
description=(
"Create hello.txt with a friendly greeting at the repo root so "
"the merge-chain scenario has a real change to assemble upward."
),
acceptance_criteria=["hello.txt exists at the repo root"],
project_id=project_id,
created_by=company.cell_pm_id,
parent_task_id=cell_id,
assigned_to=company.dev_id,
)
return {
"root_id": root_id,
"root_branch": root_branch,
"cell_id": cell_id,
"cell_branch": cell_branch,
"child_id": child_id,
}
def seed_cell_and_dev(
stack: E2EStack,
company: Company,
project_id: Any,
root: dict[str, Any],
*,
filename: str,
) -> dict[str, Any]:
"""Cell (cell-PM) → dev child under an ALREADY-EXISTING root, seeded
mid-flight — the cell/child half of :func:`seed_hierarchy`, generalized
for a caller that owns its own root (e.g. a MegaTask root-subtask whose
root is claimed/seeded separately from this cell). ``root`` carries
``root_id`` / ``root_branch`` (the shape :func:`seed_hierarchy` returns)."""
from roboco.models.base import TaskStatus, TaskType
root_id, root_branch = root["root_id"], root["root_branch"]
cell_id = uuid4()
cell_branch = f"{root_branch}--{str(cell_id)[:8]}"
origin_branch(stack, cell_branch, start=root_branch)
seed_task(
stack,
id=cell_id,
title=f"Backend slice: {filename}",
description=(
"Cell task assembling the backend slice of the feature; one dev "
"leaf writes the file, the cell PM assembles and submits."
),
acceptance_criteria=[f"{filename} exists at the repo root"],
task_type=TaskType.PLANNING,
project_id=project_id,
created_by=company.main_pm_id,
assigned_to=company.cell_pm_id,
parent_task_id=root_id,
status=TaskStatus.IN_PROGRESS,
branch_name=cell_branch,
active_claimant_id=company.cell_pm_id,
)
child_id = seed_task(
stack,
title=f"Write {filename}",
description=(
f"Create {filename} with a friendly greeting at the repo root so "
"the scenario has a real change to assemble upward."
),
acceptance_criteria=[f"{filename} exists at the repo root"],
project_id=project_id,
created_by=company.cell_pm_id,
parent_task_id=cell_id,
assigned_to=company.dev_id,
)
return {
"root_id": root_id,
"root_branch": root_branch,
"cell_id": cell_id,
"cell_branch": cell_branch,
"child_id": child_id,
}
def reviewer_gate_pass_arc(stack: E2EStack, company: Company, task_id: Any) -> None:
"""awaiting_pr_review → awaiting_pm_review via the in-path gate."""
reviewer = ScriptedAgent(
stack, company.pr_reviewer_id, "pr-reviewer-1", "pr_reviewer"
)
expect_ok(
reviewer.flow("claim_gate_review", task_id=str(task_id)),
"reviewer claim_gate_review",
)
expect_ok(
reviewer.do(
"note",
scope="learning",
task_id=str(task_id),
text=(
"Gate review learning: the assembled diff is exactly the "
"child's additive file with the integrity marker present; "
"squash-merge assembly verified against the base branch."
),
),
"reviewer learning note",
)
expect_ok(
reviewer.flow(
"pr_pass",
task_id=str(task_id),
notes=(
"Assembled diff reviewed against the base branch: exactly the "
"expected additive change, integrity markers present, no "
"scope creep — passing to the PM for merge."
),
),
"reviewer pr_pass",
)
assert task_state(stack, task_id)["status"] == "awaiting_pm_review"