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>
This commit is contained in:
Renzo F
2026-07-02 21:05:50 +02:00
committed by GitHub
co-authored by Renn F
parent 6b5691b02a
commit d1cf6ecbf3
27 changed files with 1722 additions and 373 deletions
+572
View File
@@ -0,0 +1,572 @@
"""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
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
_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.models import AgentRole, AgentStatus, Team
out = Company()
async def _run(session: AsyncSession) -> None:
def agent(slug: str, role: AgentRole, team: Team | None) -> AgentTable:
row = AgentTable(
id=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)
main_pm = agent("main-pm", AgentRole.MAIN_PM, None)
reviewer = agent("pr-reviewer-1", AgentRole.PR_REVIEWER, None)
ceo = agent("ceo", AgentRole.CEO, None)
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
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 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 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
],
),
"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 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"
+7
View File
@@ -159,6 +159,9 @@ def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
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")
@@ -302,6 +305,7 @@ def _make_admin_clone(root: Path, origin: Path) -> Path:
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
@@ -318,6 +322,9 @@ def _build_app(gh: _FakeGitHub) -> FastAPI:
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
+36 -348
View File
@@ -2,368 +2,56 @@
Every hop goes through the REAL MCP tool functions real HTTP real
gateway gates real services real git against the local origin, with a
fake GitHub REST layer whose merges are real git merges. No LLM: this file
IS the agent script, and every rejection envelope is printed verbatim so a
seam regression names itself.
fake GitHub REST layer whose merges are real git merges. No LLM: the arcs
in ``tests/e2e_smoke/arcs.py`` ARE the agent script, and every rejection
envelope prints verbatim so a seam regression names itself.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from typing import TYPE_CHECKING
import pytest
from tests.e2e_smoke.harness import (
E2EStack,
ScriptedAgent,
expect_error,
expect_ok,
from tests.e2e_smoke.arcs import (
dev_arc,
doc_arc,
qa_arc,
seed_company,
seed_project,
seed_task,
task_state,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
pytestmark = pytest.mark.usefixtures("e2e_stack")
_PROJECT_SLUG = "e2e-proj"
class _Company:
dev_id: Any
qa_id: Any
doc_id: Any
cell_pm_id: Any
project_id: Any
task_id: Any
def _seed(stack: E2EStack) -> _Company:
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.utils.crypto import encrypt_token
out = _Company()
async def _run(session: AsyncSession) -> None:
def agent(slug: str, role: AgentRole) -> AgentTable:
row = AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
session.add(row)
return row
dev = agent("be-dev-1", AgentRole.DEVELOPER)
qa = agent("be-qa", AgentRole.QA)
doc = agent("be-doc", AgentRole.DOCUMENTER)
pm = agent("be-pm", AgentRole.CELL_PM)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="E2E Project",
slug=_PROJECT_SLUG,
git_url=str(stack.origin),
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=pm.id,
is_active=True,
git_token_encrypted=encrypt_token("e2e-dummy-token"),
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Add the greeting module",
description=(
"Create greeting.txt with a friendly greeting so the smoke "
"harness has a real file change to commit, push, and merge."
),
acceptance_criteria=[
"greeting.txt exists at the repo root",
"its content greets the reader",
],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=pm.id,
team=Team.BACKEND,
confirmed_by_human=True,
# The pool→agent routing lane is the orchestrator dispatcher's
# job (not under test here); a dev container is always spawned
# with its task already routed, which give_me_work serves via
# the pre-assigned-pending lane.
assigned_to=dev.id,
)
session.add(task)
await session.flush()
out.dev_id = dev.id
out.qa_id = qa.id
out.doc_id = doc.id
out.cell_pm_id = pm.id
out.project_id = project.id
out.task_id = task.id
stack.run_db(_run)
return out
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
from tests.e2e_smoke.harness import E2EStack
def test_leaf_dev_task_reaches_pm_review(e2e_stack: E2EStack) -> None:
stack = e2e_stack
ids = _seed(stack)
task_id = str(ids.task_id)
# --- developer: discover, claim, work, PR, submit -----------------------
dev = ScriptedAgent(stack, ids.dev_id, "be-dev-1", "developer")
env = expect_ok(dev.flow("give_me_work"), "dev give_me_work")
assert env.get("task_id") == task_id, f"expected our task, got: {env}"
def _claim() -> dict:
return dev.flow(
"i_will_work_on",
task_id=task_id,
plan=(
"Create greeting.txt at the repository root containing a "
"friendly greeting, commit it on the task branch with the "
"task-prefixed message, push the branch to origin, open the "
"pull request against master, and self-verify both acceptance "
"criteria by re-reading the committed file content."
),
steps=[
{
"title": "Write greeting.txt",
"description": (
"Create greeting.txt at the repo root containing a "
"friendly greeting for the reader."
),
},
{
"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 master and re-read the "
"file to confirm both acceptance 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=[],
)
# The composed claim succeeds and STAYS; the post-claim tracing gate
# then demands the claim-time journal note — the real agent choreography
# is claim → tracing_gap → note (now claim-held) → retry short-circuits.
expect_error(_claim(), "tracing_gap", "dev first i_will_work_on")
expect_ok(
dev.do(
"note",
scope="note",
task_id=task_id,
text=(
"Initial assessment: a single additive text file at the repo "
"root satisfies both acceptance criteria; no existing code is "
"touched, so risk is minimal and the plan is a three-step "
"write/commit/PR sequence."
),
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
task_id = seed_task(
stack,
title="Add the greeting module",
description=(
"Create greeting.txt with a friendly greeting so the smoke "
"harness has a real file change to commit, push, and merge."
),
"dev note at claim",
)
expect_ok(_claim(), "dev i_will_work_on retry")
state = _task_state(stack, ids.task_id)
assert state["status"] in ("claimed", "in_progress"), state
assert state["branch_name"], f"claim did not set a branch: {state}"
workspace = stack.workspace_of(_PROJECT_SLUG, "backend", "be-dev-1")
assert workspace.is_dir(), f"workspace clone missing at {workspace}"
# F123: the agent works in the per-task worktree, not the clone root.
workdir = workspace / ".worktrees" / task_id[:8]
assert workdir.is_dir(), f"per-task worktree missing at {workdir}"
(workdir / "greeting.txt").write_text("Hello from the e2e smoke agent!\n")
expect_ok(
dev.do(
"commit",
message="Add greeting.txt with a friendly greeting",
files=["greeting.txt"],
),
"dev commit",
)
expect_ok(
dev.do(
"note",
scope="note",
task_id=task_id,
text=(
"greeting.txt 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=task_id), "dev open_pr")
state = _task_state(stack, ids.task_id)
assert state["pr_number"], f"open_pr did not record a PR: {state} / {env}"
# The i_am_done tracing gate demands: a during-work journal entry, the
# dev_notes handoff section, a reflect entry, and an artifact referencing
# every acceptance criterion (quoted verbatim in the decision note).
expect_ok(
dev.do(
"note",
scope="decision",
task_id=task_id,
text=(
"Verified both acceptance criteria on the branch: "
'"greeting.txt exists at the repo root" holds (file committed '
'at the root), and "its content greets the reader" holds '
"(content is a friendly hello). Decision: no README change "
"needed; the greeting 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=task_id,
section={
"summary": (
"Built the greeting module: greeting.txt added at the "
"repo root with a friendly greeting. Key change is one "
"additive file on the task branch; PR is open against "
"master; no risks beyond trivial content review."
)
},
),
"dev handoff section",
)
expect_ok(
dev.do(
"note",
scope="reflect",
task_id=task_id,
text=(
"Reflection: implemented the greeting task exactly per plan — "
"wrote the file, committed on the task branch, opened the PR, "
"and self-verified both acceptance criteria against the "
"committed content."
),
),
"dev reflect note",
)
expect_ok(dev.flow("i_am_done", task_id=task_id), "dev i_am_done")
assert _task_state(stack, ids.task_id)["status"] == "awaiting_qa"
# --- QA: claim the review, inspect, pass --------------------------------
qa = ScriptedAgent(stack, ids.qa_id, "be-qa", "qa")
expect_ok(qa.flow("claim_review", task_id=task_id), "qa claim_review")
expect_ok(
qa.do(
"note",
scope="learning",
task_id=task_id,
text=(
"Review learning: the greeting change is a single additive "
"file; diff inspection on the PR confirms both acceptance "
"criteria with no side effects on existing files."
),
),
"qa learning note",
)
expect_ok(
qa.flow(
"pass_review",
task_id=task_id,
notes=(
"Verified the PR diff on the fake origin: greeting.txt exists "
"at the repo root and greets the reader. Both acceptance "
"criteria hold; no regressions in the diff, and the branch "
"contains exactly the one additive commit described."
),
ac_verdicts=[
(
"greeting.txt exists at the repo root — verified in the "
"PR diff: the file is added at the repository root."
),
(
"its content greets the reader — verified: the committed "
"content is a friendly hello message."
),
],
),
"qa pass_review",
)
assert _task_state(stack, ids.task_id)["status"] == "awaiting_documentation"
# --- documenter: claim, document -----------------------------------------
doc = ScriptedAgent(stack, ids.doc_id, "be-doc", "documenter")
expect_ok(doc.flow("claim_doc_task", task_id=task_id), "doc claim_doc_task")
expect_ok(
doc.flow(
"i_documented",
task_id=task_id,
files=["greeting.txt"],
notes=(
"Documented the greeting module: greeting.txt carries the "
"user-facing greeting; no API surface changed, README "
"untouched by design."
),
),
"doc i_documented",
acceptance_criteria=[
"greeting.txt exists at the repo root",
"its content greets the reader",
],
project_id=project_id,
created_by=company.cell_pm_id,
# Pool→agent routing is the orchestrator dispatcher's job (not under
# test); a dev container is always spawned with its task already
# routed, which give_me_work serves via the pre-assigned lane.
assigned_to=company.dev_id,
)
final = _task_state(stack, ids.task_id)
dev_arc(stack, company, project_slug, task_id)
qa_arc(stack, company, task_id)
doc_arc(stack, company, task_id, filename="greeting.txt")
final = task_state(stack, task_id)
assert final["status"] == "awaiting_pm_review", final
assert final["docs_complete"] is True, final
+175
View File
@@ -0,0 +1,175 @@
"""Scenarios 2 + 2b: the PM merge chain, with and without the submit turn.
Scenario 2 (the BEFORE-net): the classic chain the cell PM completes the
child, calls ``submit_up`` itself, the reviewer gate-passes, the PM merges.
Scenario 2b (the turn cut): the child lands the same way, but the SUBMIT
turn never happens as an agent call the orchestrator's
``_try_auto_submit`` runs the real submit verb through the real API as the
owning PM, and the chain continues reviewer PM merge. One PM turn fewer
per assembled parent, with every gate intact.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import httpx
from tests.e2e_smoke.arcs import (
dev_arc,
dispatcher_assign,
doc_arc,
origin_file,
qa_arc,
reviewer_gate_pass_arc,
seed_company,
seed_hierarchy,
seed_project,
task_state,
)
from tests.e2e_smoke.harness import ScriptedAgent, expect_ok
if TYPE_CHECKING:
import pytest
from tests.e2e_smoke.arcs import Company
from tests.e2e_smoke.harness import E2EStack
def _land_child(
stack: E2EStack, company: Company, project_slug: str, h: dict
) -> ScriptedAgent:
"""Run the child through dev→QA→doc and the PM's child-completion merge."""
dev_arc(
stack,
company,
project_slug,
h["child_id"],
work=("hello.txt", "Hello from the merge chain!\n"),
)
qa_arc(stack, company, h["child_id"])
doc_arc(stack, company, h["child_id"], filename="hello.txt")
# The child's PR must target the CELL branch (ancestor resolution) —
# branch NAMES derive from the task-id chain, so assert on the base ref.
child = task_state(stack, h["child_id"])
child_pr = stack.github.prs[child["pr_number"]]
assert child_pr["base"]["ref"] == h["cell_branch"], (
f"child PR should target the cell branch: {child_pr['base']} / {child}"
)
pm = ScriptedAgent(stack, company.cell_pm_id, "be-pm", "cell_pm")
expect_ok(
pm.flow(
"complete",
task_id=str(h["child_id"]),
notes=(
"Child verified: QA passed with per-criterion verdicts and "
"docs are complete; merging the leaf PR into the cell branch."
),
),
"pm complete child",
)
assert task_state(stack, h["child_id"])["status"] == "completed"
assert origin_file(stack, h["cell_branch"], "hello.txt"), (
"child merge did not land hello.txt on the cell branch"
)
return pm
def _pm_merges_cell(
stack: E2EStack, company: Company, pm: ScriptedAgent, h: dict
) -> None:
"""Dispatcher re-claim (mirrored) + the PM's final merge turn."""
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
expect_ok(
pm.flow(
"complete",
task_id=str(h["cell_id"]),
notes=(
"Gate passed; merging the assembled cell PR into the root "
"branch and closing the cell task."
),
),
"pm complete cell",
)
assert task_state(stack, h["cell_id"])["status"] == "completed"
assert origin_file(stack, h["root_branch"], "hello.txt"), (
"cell merge did not land hello.txt on the root branch"
)
def test_pm_merge_chain_to_root_branch(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
# --- cell PM: submit the assembled cell PR (the turn 2b cuts) -----------
expect_ok(
pm.flow(
"submit_up",
task_id=str(h["cell_id"]),
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR against the root branch for the "
"in-path review gate."
),
),
"pm submit_up",
)
cell = task_state(stack, h["cell_id"])
assert cell["status"] == "awaiting_pr_review", cell
assert cell["pr_number"], cell
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
def test_auto_submit_cuts_the_pm_turn(
e2e_stack: E2EStack, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The wave-1 turn cut, end to end: no agent calls submit_up — the
orchestrator's ``_try_auto_submit`` drives the REAL submit verb through
the REAL API as the owning PM, and the gate chain continues unchanged."""
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
# --- the cut: the orchestrator submits system-side ----------------------
monkeypatch.setattr(settings, "api_url", stack.base_url)
monkeypatch.setattr(settings, "pr_gate_auto_submit_enabled", True)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._tick_handled_tasks = set()
orch._bg_tasks = set()
cell_task_dict = {
"id": str(h["cell_id"]),
"team": "backend",
"branch_name": h["cell_branch"],
"project_id": str(project_id),
"assigned_to": str(company.cell_pm_id),
"status": "in_progress",
}
async def _go() -> bool:
async with httpx.AsyncClient(timeout=60) as client:
return await orch._try_auto_submit(client, cell_task_dict, "be-pm")
assert asyncio.run(_go()) is True, "auto-submit should accept a clean parent"
cell = task_state(stack, h["cell_id"])
assert cell["status"] == "awaiting_pr_review", cell
assert cell["pr_number"], cell
# --- unchanged tail: reviewer gate + the PM's one remaining turn ---------
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
+227
View File
@@ -0,0 +1,227 @@
"""Scenario 3: the pr_fail revision loop and the root → CEO chain.
3a: the reviewer REJECTS the assembled cell PR (`pr_fail` with concrete
issues) needs_revision; the PM resumes, re-submits, and the second gate
pass rides through to the merge the loop the live fleet burned tokens on
when any link mis-routed.
3b: after the cell lands on the root branch, the Main PM submits the root
(root master PR), the reviewer gate-passes it, the Main PM's `complete`
escalates the root parent to the CEO, and the REAL CEO endpoint
(`POST /api/tasks/{id}/approve-and-merge`) squash-merges to master
`hello.txt` ends up on the origin's master, the whole company loop closed
with no LLM anywhere.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
import httpx
from tests.e2e_smoke.arcs import (
dispatcher_assign,
origin_commit,
origin_file,
reviewer_gate_pass_arc,
seed_company,
seed_hierarchy,
seed_project,
task_state,
)
from tests.e2e_smoke.harness import ScriptedAgent, expect_ok
from tests.e2e_smoke.test_pm_merge_chain import _land_child, _pm_merges_cell
if TYPE_CHECKING:
from tests.e2e_smoke.harness import E2EStack
def test_pr_fail_revision_loop(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
cell_id = str(h["cell_id"])
expect_ok(
pm.flow(
"submit_up",
task_id=cell_id,
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR for the in-path review gate."
),
),
"pm submit_up (first)",
)
reviewer = ScriptedAgent(
stack, company.pr_reviewer_id, "pr-reviewer-1", "pr_reviewer"
)
expect_ok(
reviewer.flow("claim_gate_review", task_id=cell_id),
"reviewer claim_gate_review (first)",
)
expect_ok(
reviewer.do(
"note",
scope="learning",
task_id=cell_id,
text=(
"Gate review learning: the assembled diff is missing a "
"trailing newline convention the root branch enforces — "
"sending back with a concrete fix."
),
),
"reviewer learning note (fail pass)",
)
expect_ok(
reviewer.flow(
"pr_fail",
task_id=cell_id,
issues=[
"hello.txt should end with exactly one trailing newline "
"per the root branch's file conventions."
],
),
"reviewer pr_fail",
)
assert task_state(stack, h["cell_id"])["status"] == "needs_revision"
# The revision dispatcher routes the assembled task back to its PM;
# mirror that hand-back, then the PM resumes and re-submits.
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
expect_ok(
pm.flow(
"i_will_plan",
task_id=cell_id,
plan=(
"Address the gate's concrete issue and re-submit the cell PR "
"for a clean pass through the in-path review gate."
),
approach=(
"Take the reviewer's single concrete finding — hello.txt must "
"end with exactly one trailing newline per the root branch's "
"file conventions — verify the file on the cell branch already "
"satisfies it, re-check the assembled diff against the root "
"branch for any other convention drift, and then re-run "
"submit_up so the gate reviews a corrected, freshly assembled "
"cell PR."
),
sub_tasks=[
{
"title": "Verify the newline convention",
"description": (
"Confirm hello.txt on the cell branch ends with exactly "
"one trailing newline as the reviewer's finding requires."
),
},
{
"title": "Re-submit the assembled PR",
"description": (
"Run submit_up again so the freshness and integrity "
"checks re-assemble the cell PR for a clean gate pass."
),
},
],
),
"pm i_will_plan after pr_fail",
)
# The unchanged-PR hard gate (0.14.0) refuses a resubmit until new work
# advances the cell branch HEAD — land the dev's fix, then resubmit.
origin_commit(
stack,
h["cell_branch"],
"hello.txt",
"Hello from the merge chain, with tidy newline conventions!\n",
f"[{str(h['child_id'])[:8]}] fix: normalize hello.txt trailing newline",
)
expect_ok(
pm.flow(
"submit_up",
task_id=cell_id,
notes=(
"Revision addressed: file conventions verified against the "
"root branch; re-assembling the cell PR for the gate."
),
),
"pm submit_up (resubmit)",
)
assert task_state(stack, h["cell_id"])["status"] == "awaiting_pr_review"
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
def test_root_chain_lands_on_master_via_ceo(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
# Cell lands on the root branch exactly as scenario 2 proved.
pm = _land_child(stack, company, project_slug, h)
expect_ok(
pm.flow(
"submit_up",
task_id=str(h["cell_id"]),
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR for the in-path review gate."
),
),
"pm submit_up",
)
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
# --- Main PM: submit the root → master PR, gate, complete → escalate ----
main_pm = ScriptedAgent(stack, company.main_pm_id, "main-pm", "main_pm")
root_id = str(h["root_id"])
expect_ok(
main_pm.flow(
"submit_root",
task_id=root_id,
notes=(
"Every cell task is terminal and assembled on the root "
"branch; opening the root PR against master for the gate."
),
),
"main_pm submit_root",
)
root = task_state(stack, h["root_id"])
assert root["status"] == "awaiting_pr_review", root
assert root["pr_number"], root
reviewer_gate_pass_arc(stack, company, h["root_id"])
dispatcher_assign(stack, h["root_id"], company.main_pm_id)
expect_ok(
main_pm.flow(
"complete",
task_id=root_id,
notes=(
"Gate passed on the assembled root PR; approving the root "
"parent and escalating to the CEO for the merge decision."
),
),
"main_pm complete root",
)
assert task_state(stack, h["root_id"])["status"] == "awaiting_ceo_approval"
# --- the human gate: the REAL CEO endpoint merges to master --------------
resp = httpx.post(
f"{stack.base_url}/api/tasks/{root_id}/approve-and-merge",
headers={
"X-Agent-ID": str(company.ceo_id),
"X-Agent-Role": "ceo",
},
timeout=60,
)
assert resp.status_code == HTTPStatus.OK, (
f"approve-and-merge: {resp.status_code} {resp.text[:1500]}"
)
assert task_state(stack, h["root_id"])["status"] == "completed"
assert origin_file(stack, "master", "hello.txt"), (
"the CEO merge did not land hello.txt on master"
)
@@ -159,3 +159,37 @@ async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService())
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
assert resp.pending_pitches == []
@pytest.mark.asyncio
async def test_search_tasks_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.search_tasks(_db(), _agent(AgentRole.DEVELOPER), q="greeting")
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CEO refers to tasks by NAME in the Secretary chat — the search
resolves names to ids so a directive can target the right task."""
row = MagicMock()
row.id = uuid4()
row.title = "Rework the greeting banner"
row.status = "pending"
row.team = "backend"
row.priority = 2
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[row])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
out = await sec_route.search_tasks(_db(), _agent(AgentRole.SECRETARY), q="greeting")
assert out == [
{
"id": str(row.id),
"title": "Rework the greeting banner",
"status": "pending",
"team": "backend",
"priority": 2,
}
]
+43
View File
@@ -3642,3 +3642,46 @@ async def test_pm_merge_auto_completes_without_double_completion(
# complete_task_for_agent must NOT have been called: the task was already
# auto-completed by _auto_complete_on_merge inside merge_pr_for_task.
complete_for_agent_spy.assert_not_called()
@pytest.mark.asyncio
async def test_summary_search_matches_title_description_and_id(
task_client: dict,
) -> None:
"""The task list search covers title, description (details/keywords),
and id prefix server-side, because summaries deliberately exclude
descriptions (CEO reMarkable item: task search bar)."""
client = task_client["client"]
hit_title = _seed_task(task_client, title="Rework the greeting banner")
hit_desc = _seed_task(
task_client,
title="Unrelated title",
description="Contains the zanzibar keyword deep in the details.",
)
miss = _seed_task(task_client, title="Nothing to see here")
await task_client["db"].flush()
by_title = await client.get("/api/tasks/summary?q=greeting", headers=_HDR)
assert by_title.status_code == HTTPStatus.OK
ids = {t["id"] for t in by_title.json()}
assert str(hit_title.id) in ids and str(miss.id) not in ids
by_desc = await client.get("/api/tasks/summary?q=zanzibar", headers=_HDR)
ids = {t["id"] for t in by_desc.json()}
assert str(hit_desc.id) in ids and str(hit_title.id) not in ids
prefix = str(hit_title.id)[:8]
by_id = await client.get(f"/api/tasks/summary?q={prefix}", headers=_HDR)
ids = {t["id"] for t in by_id.json()}
assert str(hit_title.id) in ids
@pytest.mark.asyncio
async def test_summary_search_respects_team_filter(task_client: dict) -> None:
client = task_client["client"]
hit = _seed_task(task_client, title="Backend greeting search hit")
await task_client["db"].flush()
resp = await client.get("/api/tasks/summary?q=greeting&team=frontend", headers=_HDR)
assert resp.status_code == HTTPStatus.OK
assert str(hit.id) not in {t["id"] for t in resp.json()}
@@ -0,0 +1,143 @@
"""The PR-gate turn cut: closure auto-submits assembled parents to the gate.
When every child of an assembled parent is terminal, the orchestrator used
to spawn the PM just to call submit_up/submit_root a whole agent turn
whose substance (freshness rebase, integrity check, PR open) is
deterministic gate code. ``_try_auto_submit`` runs the REAL submit verb
through the internal API as the owning PM; only a gate rejection falls
back to the classic PM closure spawn. The PM's remaining turn is the one
that needs judgment: the final merge (or the revision).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AGENT_UUIDS, AgentOrchestrator
# The commit/notes validator's minimum substantive length.
_MIN_NOTES = 20
_CELL_TASK: dict[str, Any] = {
"id": "11111111-1111-1111-1111-111111111111",
"team": "backend",
"branch_name": "feature/backend/AAAA1111",
"project_id": "22222222-2222-2222-2222-222222222222",
"assigned_to": "33333333-3333-3333-3333-333333333333",
"status": "in_progress",
}
def _orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._tick_handled_tasks = set()
orch._bg_tasks = set()
return orch
def _client(envelope: dict[str, Any]) -> MagicMock:
response = MagicMock()
response.json.return_value = envelope
client = MagicMock()
client.post = AsyncMock(return_value=response)
return client
@pytest.mark.asyncio
async def test_cell_parent_auto_submits_as_owning_pm(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is True
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/cell_pm/submit_up"
assert kwargs["headers"]["X-Agent-ID"] == _CELL_TASK["assigned_to"]
assert kwargs["headers"]["X-Agent-Role"] == "cell_pm"
assert kwargs["json"]["task_id"] == _CELL_TASK["id"]
assert len(kwargs["json"]["notes"]) >= _MIN_NOTES
@pytest.mark.asyncio
async def test_main_pm_root_auto_submits_submit_root(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "team": "main_pm"}
assert await orch._try_auto_submit(client, task, "main-pm") is True
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/main_pm/submit_root"
assert kwargs["headers"]["X-Agent-Role"] == "main_pm"
@pytest.mark.asyncio
async def test_branchless_parent_never_auto_submits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A branchless coordination parent (MegaTask umbrella) assembles no PR."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"error": None})
task = {**_CELL_TASK, "branch_name": None}
assert await orch._try_auto_submit(client, task, "be-pm") is False
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", False)
orch = _orch()
client = _client({"error": None})
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_gate_rejection_falls_back_to_pm_spawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A rejection envelope (e.g. integrity/freshness refusal) means the PM
turn is genuinely needed auto-submit yields to the closure spawn."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client(
{"error": "invalid_state", "message": "assembled branch behind base"}
)
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
client.post.assert_called_once()
@pytest.mark.asyncio
async def test_missing_assignment_falls_back_to_static_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "assigned_to": None}
assert await orch._try_auto_submit(client, task, "be-pm") is True
(_, kwargs) = client.post.call_args
assert kwargs["headers"]["X-Agent-ID"] == AGENT_UUIDS["be-pm"]
@pytest.mark.asyncio
async def test_transport_error_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = MagicMock()
client.post = AsyncMock(side_effect=RuntimeError("api down"))
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
+38
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
import pytest
@@ -114,3 +115,40 @@ def test_content_type_for_role_none_for_sectionless_roles() -> None:
assert content_type_for_role("head_marketing") is None
assert content_type_for_role("ceo") is None
assert content_type_for_role("prompter") is None
def test_sections_carry_written_at_stamp() -> None:
"""Every persisted section carries an ISO written_at — traces without
timestamps were unusable for reconstructing WHEN a note landed (CEO
reMarkable item, 2026-07-02)."""
t = _task()
apply_structured_note(
t,
"developer",
{
"summary": (
"Built the greeting module end to end; single additive file "
"on the task branch with the PR open against the base."
)
},
)
stored = (t.notes_structured or {})["developer"]
assert "written_at" in stored, stored
# Parseable, timezone-aware ISO-8601.
parsed = datetime.fromisoformat(stored["written_at"])
assert parsed.tzinfo is not None
def test_written_at_refreshes_on_rewrite() -> None:
t = _task()
payload = {
"summary": (
"First pass of the notes section, long enough to validate "
"against the dev section's minimum content length."
)
}
apply_structured_note(t, "developer", payload)
first = (t.notes_structured or {})["developer"]["written_at"]
apply_structured_note(t, "developer", payload)
second = (t.notes_structured or {})["developer"]["written_at"]
assert second >= first
@@ -34,6 +34,7 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
task = MagicMock()
task.approve_and_start = AsyncMock()
task.admin_set_status = AsyncMock()
task.update = AsyncMock()
monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
@@ -171,3 +172,55 @@ async def test_bad_task_action_fails_directive(
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
@pytest.mark.asyncio
async def test_confirm_control_task_edit_updates_allowlisted_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The Secretary can MODIFY a task's content fields on CEO confirmation
(reMarkable item) restricted to the safe allowlist."""
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {
"title": "Sharper title",
"priority": 1,
"description": "Clarified description from the CEO chat.",
},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].update.assert_awaited_once()
_, kwargs = svcs["task"].update.await_args
assert kwargs["title"] == "Sharper title"
assert kwargs["priority"] == 1
@pytest.mark.asyncio
async def test_control_task_edit_rejects_non_allowlisted_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Status/ownership/git fields never ride an edit — those have their own
audited paths (override, reassign)."""
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(uuid4()),
"action": "edit",
"fields": {"status": "completed"},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
svcs["task"].update.assert_not_awaited()