mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user