Files
roboco/tests/integration/test_lifecycle_real_db.py
T
5fe1e6df58 feat: in-path PR-review gate — per-cell + main reviewers (#229)
* feat(lifecycle): add the in-path PR-review gate status + reviewer verbs

Insert awaiting_pr_review between the assembled-PR submit and the PM merge,
giving the merge level the rejection capability it structurally lacks — today
only qa_fail and ceo_reject ever reach needs_revision, so a PM review is a
merge button with no teeth.

- New Status awaiting_pr_review + submit_for_review / pr_pass / pr_fail actions
  (pr_pass -> awaiting_pm_review, pr_fail -> needs_revision, mirroring the QA gate).
- Reviewer verbs claim_gate_review / pr_pass / pr_fail, and a main-PM submit_root
  verb (the root analogue of the cell PM's submit_up; opens the root->master PR).
- Extend the self-review-symmetry validator to the new sign-off actions.
- Mirror the value into the ORM TaskStatus enum + the A2A state map, and add the
  postgres taskstatus enum value (migration 040, forward-only like 037).
- Regenerate the per-role verb tables; add gate spec tests.

Spec surface only; the gateway methods + dispatch are wired in follow-ups, so the
verbs are advertised but dormant (flow_server tolerates unregistered verbs).

* feat(identity): add the three cell PR-review-gate reviewers

The in-path gate needs a reviewer per cell so each cell's assembled cell->root
PR is reviewed by a stack-specialized agent, while pr-reviewer-1 serves the
root->master gate (and keeps doing inbound external PRs).

- be/fe/ux-pr-reviewer: PR_REVIEWER role, team-scoped (so dispatch routes each
  cell's gate to its own reviewer); seeded identities + ROLE_TEAM_RULES + names.
  AI agent count 22 -> 25.
- They reuse the existing roboco-agent-pr-reviewer image (AGENT_IMAGES maps the
  three slugs to it, as be-dev-1/-2 share one image) — no new image.
- Tracing table: pr_pass/pr_fail require a learning entry (parity with
  post_pr_review), submit_root mirrors submit_up, claim_gate_review is waived
  (its tracing applies on pr_pass/pr_fail) — completes the verb surface added
  in the prior commit.
- Update the roster-pinning identity tests.

* feat(gateway): wire the in-path PR-review gate end to end

Make the assembled-PR review gate operational across the choreographer, the
TaskService transitions, and the v1 flow surface.

- TaskService: submit_for_review (in_progress→awaiting_pr_review), pr_gate_claim
  (no-transition reviewer claim), pr_pass (→awaiting_pm_review), pr_fail
  (→needs_revision); mirror qa_pass/qa_fail (clear claim, actor-mismatch warn,
  issues appended for the PM's revision). VerbRunner gains the matching atomic
  handlers + a create_root_pr side effect.
- Repoint submit_up to compose submit_for_review (cell→root PR enters the gate),
  and add a main-PM submit_root verb (opens the root→master PR, enters the gate).
- Split main_pm_complete: a code root must pass the gate first (requires
  awaiting_pm_review; rejects an in_progress code root toward submit_root and no
  longer reopens the PR), while a branchless coordination root still walks
  straight through, ungated.
- PRGateMixin (claim_gate_review / pr_pass / pr_fail) composed onto the
  Choreographer; flow_server forwarders + v1 routes (pr_reviewer + main_pm) +
  request schemas.
- Tests: gate spec + the updated submit_up / main_pm_complete expectations + new
  real-DB integration tests driving submit_for_review→pr_gate_claim→pr_pass and
  pr_fail through the real enforcement layer.

* feat(orchestrator): dispatch the in-path PR-review gate

Make the gate live in the dispatch loop.

- _dispatch_pr_gate_work: route awaiting_pr_review tasks to reviewers by level —
  a cell→root task to its cell reviewer (be/fe/ux-pr-reviewer), the root→master
  task to pr-reviewer-1. The reviewer self-claims via claim_gate_review (no
  pre-claim, mirroring the external-PR dispatcher); registered in
  _dispatch_all_work. _select_agent_for_cell learns the pr_reviewer role.
- _build_pr_gate_prompt: anchors the reviewer to the parent objective + full
  acceptance criteria + the FE<->BE contract, then pr_pass / pr_fail.
- _readiness_check_role_for_status: awaiting_pr_review -> pr_reviewer.
- Fail routing: pr_fail reassigns the failed assembled task to its PM
  (_revision_pm_for_task: cell PM for a cell team, Main PM for the root), and the
  revision dispatcher is generalized from coordination-roots-only to any
  PM-owned needs_revision task so the gate-failed task is re-coordinated instead
  of deadlocking.

* docs: document the in-path PR-review gate + the cell reviewers (22→25)

Reflect the shipped gate across the canonical + RAG docs.

- CLAUDE.md: agent count 22→25, the cell reviewers in the org chart, an
  awaiting_pr_review state + the gate transitions + a gate note in the lifecycle
  section, and submit_root / claim_gate_review / pr_pass / pr_fail in the verb
  surface table.
- docs/rag/architecture: org-structure (count, cell-reviewer roster, cells
  table), agent-uuids (be/fe/ux-pr-reviewer rows), agent-model (role + team
  rows).
- docs/rag/roles/pr-reviewer: the in-path gate section + the gate verbs.
- Wrap reviewer.id with UUID(str(...)) in the gate DB tests for mypy.

* docs: finish the gate doc sweep across README + RAG + generated artifacts

Catch the remaining surfaces beyond the canonical docs.

- README + how-to: agent count 22→25, the 6-agent cells (+ PR Reviewer), the
  main reviewer's root→master gate role.
- RAG: permissions + tool-permissions + task-tools list the gate verbs
  (claim_gate_review / pr_pass / pr_fail) for pr_reviewer; regenerate the
  lifecycle artifacts (intent-verbs, status-transitions, the per-role
  lifecycle-*.md prompts, panel lifecycle.json) from the spec via
  build_lifecycle_artifacts.py so they carry the new status + verbs.

* fix(migration): shorten the 040 revision id to fit alembic_version VARCHAR(32)

The revision id '040_taskstatus_awaiting_pr_review' is 33 chars; alembic's
alembic_version.version_num column is VARCHAR(32), so recording the migration on
a real 'alembic upgrade head' failed with 'value too long for type character
varying(32)' (surfaced on the NAS deploy). The test suite missed it: the test DB
is built via Base.metadata.create_all and the parity test only renders SQL
offline, so nothing actually applied the migration chain.

- Rename to '040_awaiting_pr_review' (22 chars).
- Add a guard test asserting every revision id fits the VARCHAR(32) column.
- Verified by applying the full chain 001->040 against real Postgres: it now
  reaches head and records '040_awaiting_pr_review' without truncation.

* fix(migration): land the actual 040 revision-id shortening + guard test

The prior commit captured only the file rename (git add aborted on the deleted
old path), leaving the long revision id and missing the guard test. This commit
carries the real content: revision id '040_awaiting_pr_review' (22 chars) and the
revision-id length guard. Re-verified against real Postgres — the full chain
reaches head and records the short id without truncation.

* fix(product): flush cell deletes before inserts when re-mapping projects

Editing a product's cell->project map (PATCH /api/products/{id}) 409'd with
'duplicate key value violates unique constraint uq_product_projects_product_team'
whenever a team already had a mapping. _replace_cells clears the old rows and
appends the new ones, but within a single flush SQLAlchemy orders INSERTs before
DELETEs for the same table, so the new (product_id, team) rows collided with the
not-yet-deleted old ones. Flush the deletes first.

Pre-existing bug (unrelated to the PR-review gate); surfaced on the NAS. New
real-Postgres regression test re-maps all three cells to different projects —
it fails with the unique violation without the fix and passes with it. The
existing update test only changed WHICH team was mapped, so it never collided.

* fix(gateway): let main_pm submit_root past the shared submit-up guard

submit_root reused the cell PM's _submit_up_ownership_guard, which
hardcoded agent.role != cell_pm and rejected the Main PM with
"submit_up is reserved for cell_pm". A branch-bearing code root could
then never close: submit_root bounced to complete, while complete
required awaiting_pm_review (reachable only via submit_root) and bounced
back — a circular rejection.

Both callers already run the spec gate (can_invoke_intent), which
enforces submit_up→cell_pm and submit_root→main_pm, so the guard's role
re-check was redundant for submit_up and wrong for submit_root. Broaden
it to accept either PM role as a defense-in-depth non-PM reject.

Adds the first choreographer-level submit_root test (the gap that let
this ship).

* fix(gateway): proactively steer both PMs to their bubble-up verb

The submit_root deadlock had a sibling steering gap: nothing told a PM
which verb opens the gate. The delegate next-hint said only 'i_am_idle
when done', and complete's in_progress rejection named submit_root for
the Main PM but left the Cell PM with a bare 'not ready for completion'
— no submit_up pointer, the same guess-the-verb trap.

- delegate hint now names the role-correct verb (root → submit_root,
  cell parent → submit_up) proactively, before any rejection.
- cell_pm_complete's in_progress rejection now steers to submit_up,
  mirroring the Main PM's submit_root gate hint.

Tests cover both the cell-PM steer and the role-aware delegate hint.

* docs: correct who-merges-which-PR across the gate docs + complete description

Audit of the gate docs found the merge actors mis-stated in several
places — the exact ambiguity that risks 'the reviewer/PM merges the root
PR' confusion:

- complete IntentSpec description said 'Main PM merges root PR' — false;
  main_pm_complete escalates and the CEO merges root→master. Corrected
  (propagated to intent-verbs.md, lifecycle.json, generated role prompts
  via build_lifecycle_artifacts.py).
- task-tools.md: submit_up target was awaiting_pm_review (should be
  awaiting_pr_review); Main PM flow had no submit_root — added it.
- README.md: lifecycle diagram now shows the awaiting_pr_review gate.
- cell-pm.md / main-pm.md: dropped the stale 'submit_up hands work to the
  Main PM who merges your cell branch' model — the cell PM merges its own
  gated cell→root PR; the Main PM owns the root + submit_root; the CEO
  merges master. Added submit_root to the main-pm manifest.
- git-commits.md, pr-creation.md, tool-permissions.md, git-tools.md:
  stopped attributing root→master PR opening to complete (it's submit_root).

No behavior change; verb wiring + state machine verified gap-free this
session (the pr_fail→needs_revision→PM respawn loop closes correctly).

* fix(orchestrator): stop closure respawn waiting the reaper window

A PM that finished its subtasks and idled left its parent 'paused' with a
fresh last_heartbeat_at. _is_recently_paused gated closure respawn on
_claim_heartbeat_ttl — the REAPER window (stale_claim_reap_seconds: 600s
default, 1800s on the NAS) — so the parent sat untouched for up to 10-30
minutes before its PM was respawned to close it. The whole chain stalled
behind it.

The race that guard actually protects against (i_am_idle auto-pauses, then
the agent is marked IDLE + its container tears down) is seconds, and the
live-session case is already covered by _is_agent_active. Introduce a
dedicated short debounce (pm_closure_recently_paused_seconds, default 45s)
and gate closure on that instead.

The existing test fixture masked this by setting _claim_heartbeat_ttl to
claim_stale_seconds (180s), not the production reaper value. Fixture now
mirrors production; adds a regression test that a parent paused past the
debounce but within the reaper window respawns immediately.

* feat(gate): post the in-path review verdict on the assembled PR

The in-path gate previously left no trace on the PR it gated — pr_pass /
pr_fail were pure status transitions. Now each verdict is posted as a
GitHub review on the assembled PR itself (server-side, bot account), so
the decision is visible on the very PR the PM merges.

- pr_pass → APPROVE, pr_fail → REQUEST_CHANGES on a cell→root PR.
- The root→master PR ALWAYS gets a plain COMMENT, never APPROVE/REQUEST_
  CHANGES: only the CEO acts on master, so the gate must never leave an
  approval that could satisfy branch protection (letting someone else
  merge) nor a blocking review that could impede the CEO's merge.
- Best-effort and AFTER the DB transition — a GitHub failure is logged,
  never rolls back the gate decision. Reuses git.post_pr_review's existing
  self-review→COMMENT downgrade for the org's own PRs.

Adds _project_slug_for to the ChoreographerHelpers protocol (mypy) and a
unit suite covering event selection, the master-bound COMMENT rule, the
no-PR skip, and failure-swallowing. Docs updated (pr-reviewer, task-tools).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-20 09:27:29 +02:00

968 lines
34 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``.
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
# A developer fresh claim must carry a substantive step checklist.
_STEPS = [
{
"title": "Implement the change",
"description": (
"edit the target file, add tests, run them, and stage the "
"change for commit on the task branch"
),
}
]
_GOOD_PLAN = (
"Implement the task on its feature branch: edit the target module, add or "
"update unit tests covering the change, run the suite locally, then commit "
"on the branch and open a PR. Keep the diff focused on the acceptance "
"criteria and verify it before submitting for QA."
)
_GOOD_TC = ["Follow the existing module's patterns; keep the change minimal."]
_GOOD_RISKS = [
{
"risk": "Scope creep balloons the diff and slows review.",
"mitigation": "Touch only the files the acceptance criteria require.",
}
]
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 push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
del agent_id, task_id
return 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 list_changed_files(
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
) -> list[str]:
del branch_name, base, actor_agent_id
return []
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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,
ac_verdicts=[f"verified: {crit}" for crit in task.acceptance_criteria],
)
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.
A cell PM completing a non-root awaiting_pm_review task transitions
straight to COMPLETED — there is no cell→main escalation in
``complete`` (the cell→main hand-off, when intended, uses
``submit_up``).
"""
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
async def _seed_reviewer(db_session: AsyncSession) -> AgentTable:
"""Add + flush a backend in-path PR-review-gate reviewer for the gate tests.
Flushed here so a later ``task.assigned_to = reviewer.id`` update can't race
the reviewer INSERT in the same unit-of-work and trip the FK constraint.
"""
reviewer = AgentTable(
id=uuid4(),
name="BE PR Reviewer",
slug="be-pr-reviewer",
role=AgentRole.PR_REVIEWER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="reviewer",
capabilities=["review"],
permissions={},
metrics={},
)
db_session.add(reviewer)
await db_session.flush()
return reviewer
@pytest.mark.asyncio
async def test_pr_review_gate_pass_path(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""in_progress → submit_for_review → awaiting_pr_review → pr_gate_claim →
pr_pass → awaiting_pm_review.
Drives the new TaskService transitions through the real enforcement layer
(validate_task_transition + validate_git_requirements) and the DB — the
layers the mocked unit tests can't exercise.
"""
task = lifecycle_setup["task"]
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
reviewer = await _seed_reviewer(db_session)
task.status = TaskStatus.IN_PROGRESS
task.pr_number = _PR_NUMBER
task.pr_url = _PR_URL
task.pr_created = True
task.assigned_to = cell_pm_agent.id
task.claimed_by = cell_pm_agent.id
await db_session.flush()
svc = TaskService(db_session)
reviewer_id = UUID(str(reviewer.id))
entered = await svc.submit_for_review(
cell_pm_agent.id, task.id, notes="cell assembled; entering review"
)
assert entered is not None
assert str(entered.status) == Status.AWAITING_PR_REVIEW.value
claimed = await svc.pr_gate_claim(reviewer_id, task.id)
assert claimed is not None
assert str(claimed.status) == Status.AWAITING_PR_REVIEW.value
assert claimed.assigned_to == reviewer.id
passed = await svc.pr_pass(reviewer_id, task.id, notes="integration verified")
assert passed is not None
assert str(passed.status) == Status.AWAITING_PM_REVIEW.value
assert passed.assigned_to is None # cleared so the PM-closure dispatch routes
final = await svc.get(task.id)
assert final is not None
assert str(final.status) == Status.AWAITING_PM_REVIEW.value
@pytest.mark.asyncio
async def test_pr_review_gate_fail_path(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""awaiting_pr_review → pr_fail → needs_revision, with issues recorded for
the PM's revision."""
task = lifecycle_setup["task"]
reviewer = await _seed_reviewer(db_session)
task.status = TaskStatus.AWAITING_PR_REVIEW
task.assigned_to = reviewer.id
task.claimed_by = reviewer.id
await db_session.flush()
svc = TaskService(db_session)
failed = await svc.pr_fail(
UUID(str(reviewer.id)),
task.id,
notes="integration seam is broken",
issues=["FE sends task_id as a string where the BE requires a UUID"],
)
assert failed is not None
assert str(failed.status) == Status.NEEDS_REVISION.value
assert "string where the BE requires a UUID" in (failed.dev_notes or "")
# ---------------------------------------------------------------------------
# 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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
risks=_GOOD_RISKS,
)
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