mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
_check_pm_decision_required now requires the latest journal:decision within pm_decision_window_seconds (default 300). Older decisions no longer satisfy the gate. Adds JournalService.latest_decision_at. Future-tighten (out of scope): per-verb-group consumption tracking would need persistent state — Choreographer is per-request today.
826 lines
29 KiB
Python
826 lines
29 KiB
Python
"""Tier 3 — end-to-end happy paths against the real test DB.
|
|
|
|
Each test exercises the spec → choreographer → TaskService → DB stack
|
|
with Alembic migrations applied. Catches "spec says X, DB constraint
|
|
says Y" mismatches the unit-tier parametrized parity suite cannot
|
|
detect.
|
|
|
|
Companion to ``tests/integration/test_full_lifecycle_real_db.py``
|
|
(audit P2-1 deliverable). That file walks one task through the dev
|
|
chain end to end; this file isolates each major lifecycle path into
|
|
its own test so a regression on, say, QA-fail does not also blow up
|
|
the doc-handoff test.
|
|
|
|
Mocks: only the git layer (workspace + PR ops) is stubbed because the
|
|
test DB has no checkout. The spec, choreographer, VerbRunner, and
|
|
TaskService are real — those are the layers Task 30 verifies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.foundation.policy.lifecycle import Status
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
Team,
|
|
)
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
from roboco.services.task import TaskService
|
|
from sqlalchemy import delete
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
_BRANCH = "feature/backend/healthz"
|
|
_PR_NUMBER = 8
|
|
_PR_URL = "https://github.com/example/life/pull/8"
|
|
# QA notes must clear settings.qa_notes_min_chars (default 80).
|
|
_QA_PASS_NOTES = (
|
|
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
|
|
"both acceptance criteria. Approving."
|
|
)
|
|
_QA_FAIL_NOTES_PREFIX = (
|
|
"Reviewed the diff; route returns 500 on the timestamp branch and the "
|
|
"second acceptance criterion is not exercised by the new tests. "
|
|
)
|
|
|
|
|
|
class _StubGit:
|
|
"""Deterministic GitService stub.
|
|
|
|
Mirrors ``test_full_lifecycle_real_db.py``'s _StubGit. Mutates the
|
|
test's TaskTable row directly so the choreographer reads consistent
|
|
pr_number / commits state without disk or network I/O.
|
|
"""
|
|
|
|
def __init__(self, session: Any, task: TaskTable) -> None:
|
|
self._session = session
|
|
self._task = task
|
|
|
|
async def commit(
|
|
self,
|
|
*,
|
|
branch_name: str,
|
|
message: str,
|
|
task_id: UUID,
|
|
files: list[str] | None = None,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, files, actor_agent_id
|
|
sha = uuid4().hex[:40]
|
|
commits = list(self._task.commits or [])
|
|
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
|
|
self._task.commits = commits
|
|
await self._session.flush()
|
|
return {
|
|
"sha": sha,
|
|
"message": message,
|
|
"files_changed": 1,
|
|
"insertions": 1,
|
|
"deletions": 0,
|
|
}
|
|
|
|
async def push_branch(
|
|
self, branch_name: str, *, actor_agent_id: Any = None
|
|
) -> tuple[str, int]:
|
|
del branch_name, actor_agent_id
|
|
return ("ok", 0)
|
|
|
|
async def create_pr(
|
|
self,
|
|
branch_name: str,
|
|
*,
|
|
parent: str,
|
|
is_root_pr: bool,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, parent, actor_agent_id
|
|
self._task.pr_number = _PR_NUMBER
|
|
self._task.pr_url = _PR_URL
|
|
self._task.pr_created = True
|
|
await self._session.flush()
|
|
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
|
|
|
|
async def diff(
|
|
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
|
|
) -> str:
|
|
del branch_name, base, actor_agent_id
|
|
return "stub diff"
|
|
|
|
async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
|
|
del pr_number, actor_agent_id
|
|
return "main"
|
|
|
|
async def pr_merge(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
del args, kwargs
|
|
return {
|
|
"merged": True,
|
|
"sha": uuid4().hex[:40],
|
|
"merge_commit_sha": uuid4().hex[:40],
|
|
}
|
|
|
|
|
|
def _mock_evidence_repo() -> Any:
|
|
repo = AsyncMock()
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
"journal_highlights_for_task",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
return repo
|
|
|
|
|
|
def _mock_journal_with_reflect() -> Any:
|
|
"""Journal stub that reports reflect/learning/decision entries present.
|
|
|
|
``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8
|
|
recency window on the PM-decision gate accepts it.
|
|
"""
|
|
journal = AsyncMock()
|
|
journal.has_reflect_for_task.return_value = True
|
|
journal.has_learning_for_task.return_value = True
|
|
journal.has_decision_for_task.return_value = True
|
|
journal.has_struggle_for_task.return_value = False
|
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
|
return journal
|
|
|
|
|
|
def _mock_work_session() -> Any:
|
|
"""WorkSession stub: stable file list, no unpushed commits."""
|
|
ws = AsyncMock()
|
|
ws.files_changed.return_value = ["roboco/api/routes/health.py"]
|
|
ws.has_unpushed_commits.return_value = False
|
|
return ws
|
|
|
|
|
|
def _build_choreographer(
|
|
db_session: Any, task: TaskTable, task_service: TaskService
|
|
) -> Choreographer:
|
|
"""Wire a real Choreographer with the supplied TaskService + stubbed git.
|
|
|
|
Caller owns the TaskService so it can use it for direct DB reads
|
|
(``task_service.get(task_id)``) — sharing one instance keeps the
|
|
session contract clean and avoids "two TaskServices, two views"
|
|
surprises.
|
|
"""
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=_StubGit(db_session, task),
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
return Choreographer(deps)
|
|
|
|
|
|
async def _seed_agents_and_project(
|
|
db_session: AsyncSession,
|
|
) -> dict[str, Any]:
|
|
"""Seed system + project + dev/qa/doc/cell_pm agents.
|
|
|
|
Slugs match ``agents_config.ESCALATION_CHAIN`` so ``i_am_blocked``
|
|
and ``escalate_up`` find a real escalation target. The agents-config
|
|
chain is the source of truth at runtime; matching it here exercises
|
|
the same lookup the gateway uses in production.
|
|
"""
|
|
system_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="System",
|
|
slug=f"system-{uuid4().hex[:8]}",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="system",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(system_agent)
|
|
await db_session.flush()
|
|
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Lifecycle Test Project",
|
|
slug=f"life-{uuid4().hex[:8]}",
|
|
git_url="https://github.com/example/life.git",
|
|
default_branch="main",
|
|
protected_branches=["main"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=system_agent.id,
|
|
is_active=True,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
|
|
dev_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Dev 1",
|
|
slug="be-dev-1",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=["python"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
qa_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE QA",
|
|
slug="be-qa",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=["review"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
doc_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Doc",
|
|
slug="be-doc",
|
|
role=AgentRole.DOCUMENTER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="doc",
|
|
capabilities=["docs"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
cell_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Cell PM",
|
|
slug="be-pm",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="cell_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
|
|
await db_session.flush()
|
|
|
|
return {
|
|
"system_agent": system_agent,
|
|
"project": project,
|
|
"dev_agent": dev_agent,
|
|
"qa_agent": qa_agent,
|
|
"doc_agent": doc_agent,
|
|
"cell_pm_agent": cell_pm_agent,
|
|
}
|
|
|
|
|
|
def _build_task(
|
|
*,
|
|
project_id: UUID,
|
|
creator_id: UUID,
|
|
assignee_id: UUID | None,
|
|
status: TaskStatus,
|
|
) -> TaskTable:
|
|
"""Construct a backend code-typed task pinned to ``status``.
|
|
|
|
``acceptance_criteria_status`` carries the stub artefact rows the
|
|
pre-merge gate inspects; supplying them here keeps the per-test
|
|
setup readable.
|
|
"""
|
|
return TaskTable(
|
|
id=uuid4(),
|
|
title="Add /healthz endpoint",
|
|
description="Return 200 OK from /healthz",
|
|
status=status,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
team=Team.BACKEND,
|
|
project_id=project_id,
|
|
created_by=creator_id,
|
|
assigned_to=assignee_id,
|
|
branch_name=_BRANCH,
|
|
acceptance_criteria=["Returns 200", "Includes timestamp"],
|
|
acceptance_criteria_status=[
|
|
{"criterion": "Returns 200", "referencing_artifact_id": "stub"},
|
|
{"criterion": "Includes timestamp", "referencing_artifact_id": "stub"},
|
|
],
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def lifecycle_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict[str, Any]]:
|
|
"""Seed agents + project + a single PENDING task assigned to the dev."""
|
|
seeded = await _seed_agents_and_project(db_session)
|
|
task = _build_task(
|
|
project_id=seeded["project"].id,
|
|
creator_id=seeded["system_agent"].id,
|
|
assignee_id=seeded["dev_agent"].id,
|
|
status=TaskStatus.PENDING,
|
|
)
|
|
db_session.add(task)
|
|
await db_session.flush()
|
|
seeded["task"] = task
|
|
yield seeded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Dev path: pending → claimed → in_progress → verifying → awaiting_qa
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_full_chain_through_awaiting_qa(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""pending → claimed → in_progress → verifying → awaiting_qa.
|
|
|
|
Drives ``i_will_work_on`` (claim+plan+start), a stubbed commit,
|
|
``open_pr``, then ``i_am_done`` which auto-runs submit_verification
|
|
+ submit_qa. Asserts the final DB row sits at ``awaiting_qa``.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
task_service = TaskService(db_session)
|
|
stub_git = _StubGit(db_session, task)
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=stub_git,
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
# Commit + record progress so open_pr's commits-precondition holds.
|
|
await stub_git.commit(
|
|
branch_name=_BRANCH,
|
|
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
|
|
task_id=task.id,
|
|
)
|
|
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
|
|
|
|
env = await c.open_pr(dev_agent.id, task.id)
|
|
assert env.error is None, f"open_pr failed: {env.message}"
|
|
|
|
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
|
|
assert env.error is None, f"i_am_done failed: {env.message}"
|
|
assert env.status == Status.AWAITING_QA.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_QA.value
|
|
# i_am_done auto-runs submit_qa, which hands the task off to the
|
|
# backend QA agent (production behaviour — see ``_notify_qa``).
|
|
# Resolve via the same lookup the choreographer uses so the
|
|
# assertion is robust to other test fixtures that may have seeded
|
|
# additional QA agents on the BACKEND team (e.g. smoke_test_batch
|
|
# commits its agents, so they outlive their session).
|
|
resolved_qa = await task_service.qa_agent_for_team(Team.BACKEND)
|
|
assert resolved_qa is not None
|
|
assert final.assigned_to == resolved_qa.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. QA pass path: awaiting_qa → claimed → awaiting_documentation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_pass_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → pass_review → awaiting_documentation.
|
|
|
|
``claim_review`` keeps status at AWAITING_QA (specialised qa_claim
|
|
sets assignment without transitioning) so the spec's source-status
|
|
requirement on ``qa_pass`` still matches downstream.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
|
|
# Pin the task at awaiting_qa with a PR + commit so the QA gates
|
|
# (pr exists, commits non-empty) all pass.
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_QA.value
|
|
assert after_claim.assigned_to == qa_agent.id
|
|
|
|
env = await c.pass_review(qa_agent.id, task.id, notes=_QA_PASS_NOTES)
|
|
assert env.error is None, f"pass_review failed: {env.message}"
|
|
assert env.status == Status.AWAITING_DOCUMENTATION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_DOCUMENTATION.value
|
|
# pass_review reassigns to the team's documenter for handoff. Look
|
|
# up via the same path the choreographer uses (robust to other
|
|
# tests' committed BACKEND documenters).
|
|
resolved_doc = await task_service.documenter_for_team(Team.BACKEND)
|
|
assert resolved_doc is not None
|
|
assert final.assigned_to == resolved_doc.id
|
|
del doc_agent # asserted indirectly via documenter_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. QA fail path: awaiting_qa → claimed → needs_revision
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_fail_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → fail_review(issues) → needs_revision.
|
|
|
|
``fail_review`` reassigns to the original developer so they can
|
|
revise; that lookup walks ``quick_context``'s
|
|
``original_developer:<slug>`` marker, which the dev path stamps
|
|
on i_will_work_on. Here we set it directly so the assertion is
|
|
deterministic without re-running the dev chain.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
# ``extract_original_developer`` parses a UUID off this line; the
|
|
# spec layer's slug-based self-review check is a separate code path
|
|
# (``_extract_original_developer`` in qa.py) which only fires when
|
|
# an actor's slug equals this value, so a UUID here doesn't trip it.
|
|
task.quick_context = f"original_developer:{dev_agent.id}"
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
|
|
issues = ["Returns 500 on the timestamp branch", "Missing test for the second AC"]
|
|
# fail_review's notes are derived from issues; QA pass-gate also
|
|
# requires notes >= 80 chars, so we send a leading explanation as
|
|
# the issues list — the verb concatenates them and easily clears
|
|
# the threshold.
|
|
long_issues = [_QA_FAIL_NOTES_PREFIX + issues[0], issues[1]]
|
|
env = await c.fail_review(qa_agent.id, task.id, issues=long_issues)
|
|
assert env.error is None, f"fail_review failed: {env.message}"
|
|
assert env.status == Status.NEEDS_REVISION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.NEEDS_REVISION.value
|
|
# fail_qa reassigns to the original developer so they can revise.
|
|
assert final.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Doc path: awaiting_documentation → claimed → awaiting_pm_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_doc_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_documentation → claim_doc_task → i_documented → awaiting_pm_review.
|
|
|
|
``claim_doc_task`` keeps status at AWAITING_DOCUMENTATION (doc_claim
|
|
is assignment-only, mirroring qa_claim). ``i_documented`` flips to
|
|
AWAITING_PM_REVIEW and reassigns to the cell PM for that team.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.assigned_to = None # documenter must claim from unassigned.
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_doc_task(doc_agent.id, task.id)
|
|
assert env.error is None, f"claim_doc_task failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_DOCUMENTATION.value
|
|
assert after_claim.assigned_to == doc_agent.id
|
|
|
|
env = await c.i_documented(
|
|
doc_agent.id,
|
|
task.id,
|
|
notes="Documented /healthz behaviour in docs/api/health.md",
|
|
files=["docs/api/health.md"],
|
|
)
|
|
assert env.error is None, f"i_documented failed: {env.message}"
|
|
assert env.status == Status.AWAITING_PM_REVIEW.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_PM_REVIEW.value
|
|
# i_documented hands off to the cell PM for the team. Resolve via
|
|
# the same lookup the choreographer uses (robust to other tests'
|
|
# committed BACKEND cell PMs).
|
|
resolved_pm = await task_service.cell_pm_for_team(Team.BACKEND)
|
|
assert resolved_pm is not None
|
|
assert final.assigned_to == resolved_pm.id
|
|
del cell_pm_agent # asserted indirectly via cell_pm_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. PM complete (Cell PM, simple task): awaiting_pm_review → completed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_complete_simple_task(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → cell_pm complete → completed.
|
|
|
|
With no Main PM seeded, ``_handle_cell_pm_escalation`` short-circuits
|
|
and the task transitions straight to COMPLETED instead of being
|
|
handed up to the Main PM. This is the "simple task" path the plan
|
|
calls out — no parent task, no Main PM, no CEO escalation.
|
|
|
|
Test isolation note: ``test_groups_routes.py`` exercises the
|
|
groups POST endpoint, which commits via ``db.commit()`` and
|
|
persists a MAIN_PM agent across sessions in the test DB. We
|
|
delete any pre-existing MAIN_PM rows at the top of this test so
|
|
the cell PM completion is the only one in play. The delete runs
|
|
inside this test's session and is unwound by the conftest's
|
|
rollback, so committed state in the shared DB is untouched.
|
|
"""
|
|
await db_session.execute(
|
|
delete(AgentTable).where(AgentTable.role == AgentRole.MAIN_PM)
|
|
)
|
|
await db_session.flush()
|
|
|
|
task = lifecycle_setup["task"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.assigned_to = cell_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.complete(
|
|
cell_pm_agent.id, task.id, notes="LGTM — merging the leaf PR."
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.COMPLETED.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.COMPLETED.value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. PM escalate: awaiting_pm_review → awaiting_ceo_approval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_escalate_to_ceo_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → main_pm complete on a root task → awaiting_ceo_approval.
|
|
|
|
Main PM completing a root task (no parent) opens the master PR if
|
|
needed and calls ``task.escalate_to_ceo``, leaving the task at
|
|
AWAITING_CEO_APPROVAL with ``assigned_to=None``. CEO approval is
|
|
human-in-the-loop (UI-driven), so the test stops there.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
project = lifecycle_setup["project"]
|
|
system_agent = lifecycle_setup["system_agent"]
|
|
|
|
main_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="main_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm_agent)
|
|
await db_session.flush()
|
|
del project, system_agent # only needed for fixture wiring above.
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks.
|
|
task.assigned_to = main_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# SQLAlchemy column-typed `id` attributes need an explicit UUID
|
|
# cast for mypy under the project's strict config — the values are
|
|
# already real ``uuid.UUID`` at runtime.
|
|
env = await c.complete(
|
|
UUID(str(main_pm_agent.id)),
|
|
UUID(str(task.id)),
|
|
notes="Root task ready for CEO approval — escalating.",
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.AWAITING_CEO_APPROVAL.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_CEO_APPROVAL.value
|
|
# main_pm_complete clears assigned_to so the orchestrator does not
|
|
# respawn an agent while the task waits on the human CEO.
|
|
assert final.assigned_to is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Block + unblock + restore: in_progress → blocked → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_then_unblock_restore(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_blocked → unblock(restore=True) → in_progress.
|
|
|
|
``i_am_blocked`` runs the spec's ``block`` action which delegates
|
|
to ``task_service.escalate``: the task is reassigned to the dev's
|
|
escalation target (``be-pm`` per ``ESCALATION_CHAIN``) and marked
|
|
BLOCKED with the original dev stashed in ``blocker_raised_by``.
|
|
PM ``unblock(restore=True)`` then falls through to the legacy
|
|
``unblock`` path (no ``pre_block_state`` snapshot exists for chain
|
|
escalations), restoring assignment to the dev and flipping back
|
|
to IN_PROGRESS.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# Drive into in_progress via the real claim+start sequence.
|
|
env = await c.i_will_work_on(dev_agent.id, task.id, plan="implement /healthz")
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_blocked(
|
|
dev_agent.id,
|
|
task.id,
|
|
reason="external dependency on auth library upgrade",
|
|
)
|
|
assert env.error is None, f"i_am_blocked failed: {env.message}"
|
|
assert env.status == Status.BLOCKED.value
|
|
|
|
blocked = await task_service.get(task.id)
|
|
assert blocked is not None
|
|
assert str(blocked.status) == Status.BLOCKED.value
|
|
# Escalation reassigns to the cell PM (be-pm) and stashes the dev
|
|
# as blocker_raised_by so unblock can hand the task back.
|
|
assert blocked.assigned_to == cell_pm_agent.id
|
|
assert blocked.blocker_raised_by == dev_agent.id
|
|
|
|
env = await c.unblock(cell_pm_agent.id, task.id, restore=True)
|
|
assert env.error is None, f"unblock failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
restored = await task_service.get(task.id)
|
|
assert restored is not None
|
|
assert str(restored.status) == Status.IN_PROGRESS.value
|
|
# legacy unblock restores assigned_to from blocker_raised_by — the
|
|
# original dev gets the task back so the orchestrator respawns them.
|
|
assert restored.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Pause + resume: in_progress → paused → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_then_resume(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_idle (auto-pause) → resume → in_progress.
|
|
|
|
There is no agent-driven ``pause`` verb. ``i_am_idle`` auto-pauses
|
|
every in_progress task the agent owns so the closure dispatcher can
|
|
wake them on respawn. ``resume`` (composes=("resume",)) then flips
|
|
the same task back to IN_PROGRESS for the same assignee.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.i_will_work_on(dev_agent.id, task.id, plan="implement /healthz")
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_idle(dev_agent.id)
|
|
assert env.error is None, f"i_am_idle failed: {env.message}"
|
|
assert env.status == "idle"
|
|
|
|
paused = await task_service.get(task.id)
|
|
assert paused is not None
|
|
assert str(paused.status) == Status.PAUSED.value
|
|
# Auto-pause keeps assigned_to so resume can find the same claimant.
|
|
assert paused.assigned_to == dev_agent.id
|
|
|
|
env = await c.resume(dev_agent.id, task.id)
|
|
assert env.error is None, f"resume failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
resumed = await task_service.get(task.id)
|
|
assert resumed is not None
|
|
assert str(resumed.status) == Status.IN_PROGRESS.value
|
|
assert resumed.assigned_to == dev_agent.id
|