diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index cf6832f6..a9e81b96 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -3321,7 +3321,10 @@ class Choreographer: """Cross-cell sequencing: in a product fan-out the FRONTEND cell task depends on the UX/UI cell task — UX design is upstream of frontend implementation, while backend runs in parallel. Wires the dependency in - either delegation order. Best-effort: never breaks delegate. + either delegation order. A dev/code subtask delegated under a cell task + that is itself still waiting on that dependency inherits it, so the + developer is held until UX is done instead of coding ahead of the + design. Best-effort: never breaks delegate. """ if parent is None or getattr(parent, "product_id", None) is None: return @@ -3329,6 +3332,9 @@ class Choreographer: nt_team = self._team_value(new_task.team) try: + # A subtask of a cell task that is waiting on another cell must wait + # too — propagate the parent's unmet cross-cell dependencies down. + await self.task.inherit_unmet_dependencies(new_task.id, parent.id) if nt_team == Team.FRONTEND.value: await self._depend_frontend_on_ux(new_task, parent.id) elif nt_team == Team.UX_UI.value: diff --git a/roboco/services/task.py b/roboco/services/task.py index a6c0fd6b..8958498d 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -4192,6 +4192,32 @@ class TaskService(BaseService): task.dependency_ids = [*task.dependency_ids, depends_on_id] await self.session.flush() + async def inherit_unmet_dependencies( + self, subtask_id: UUID, parent_id: UUID + ) -> None: + """Copy a parent's still-unresolved dependencies onto a subtask. + + A subtask of a task that is itself waiting on a cross-cell dependency + (e.g. a frontend dev subtask under a frontend cell task that waits on + the UX/UI design) must be held until that dependency resolves — the + developer cannot code ahead of the design. Only non-terminal parent + dependencies are inherited; already-completed ones would never release + the subtask via `_unblock_dependents`. Reuses `add_dependency`, so the + subtask is held by `list_pending(filter_by_dependencies=True)`. + """ + parent = await self.get(parent_id) + if parent is None or not parent.dependency_ids: + return + dep_result = await self.session.execute( + select(TaskTable.id, TaskTable.status).where( + TaskTable.id.in_(parent.dependency_ids) + ) + ) + terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED} + for dep_id, dep_status in dep_result.all(): + if dep_status not in terminal: + await self.add_dependency(subtask_id, dep_id) + async def get_subtasks(self, parent_task_id: UUID) -> list[TaskTable]: """Get all subtasks of a parent task.""" result = await self.session.execute( diff --git a/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py b/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py new file mode 100644 index 00000000..ad6acf22 --- /dev/null +++ b/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py @@ -0,0 +1,226 @@ +"""A dev/code subtask under a frontend cell task that is waiting on the UX/UI +design must inherit the unresolved cross-cell dependency, so the developer is +held until UX is done instead of coding ahead of the design. + +The frontend CELL task already waits on the UX/UI cell task (cross-cell +sequencing). When the cell PM delegates a dev subtask under that cell task, the +subtask must NOT become dispatchable while the UX dependency is unresolved, and +must become dispatchable once the UX task reaches a terminal state. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +import pytest_asyncio +from roboco.db.tables import AgentTable, ProductTable, ProjectTable +from roboco.models import AgentRole, AgentStatus, Team +from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType +from roboco.models.task import TaskCreateRequest +from roboco.services.gateway.choreographer._impl import ( + Choreographer, + ChoreographerDeps, +) +from roboco.services.task import TaskService + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest_asyncio.fixture +async def fanout_setup(db_session: AsyncSession) -> AsyncIterator[dict]: + system = AgentTable( + id=uuid4(), + name="System", + slug=f"system-{uuid4().hex[:8]}", + role=AgentRole.SYSTEM, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="s", + capabilities=[], + permissions={}, + metrics={}, + ) + fe_dev = AgentTable( + id=uuid4(), + name="FE Dev", + slug=f"fe-dev-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=Team.FRONTEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="s", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add_all([system, fe_dev]) + await db_session.flush() + fe_project = ProjectTable( + id=uuid4(), + name="FE", + slug=f"fe-{uuid4().hex[:6]}", + git_url="https://example.com/fe.git", + assigned_cell=Team.FRONTEND, + created_by=system.id, + ) + ux_project = ProjectTable( + id=uuid4(), + name="UX", + slug=f"ux-{uuid4().hex[:6]}", + git_url="https://example.com/ux.git", + assigned_cell=Team.UX_UI, + created_by=system.id, + ) + product = ProductTable( + id=uuid4(), + name="Prod", + slug=f"prod-{uuid4().hex[:6]}", + created_by=system.id, + ) + db_session.add_all([fe_project, ux_project, product]) + await db_session.flush() + + svc = TaskService(db_session) + choreo = Choreographer( + ChoreographerDeps( + task=svc, + work_session=None, + git=None, + a2a=None, + journal=None, + audit=None, + evidence_repo=None, + ) + ) + yield { + "svc": svc, + "choreo": choreo, + "creator": system.id, + "fe_dev_id": fe_dev.id, + "fe_project_id": fe_project.id, + "ux_project_id": ux_project.id, + "product_id": product.id, + } + + +async def _build_product_fanout(setup: dict) -> dict: + """Product root with FE + UX cell tasks as siblings, FE waiting on UX.""" + svc: TaskService = setup["svc"] + choreo: Choreographer = setup["choreo"] + + root = await svc.create( + TaskCreateRequest( + title="Build the feature (board fan-out)", + description="a real coordination task description over twenty chars", + acceptance_criteria=["delegated to frontend + ux_ui cells"], + team=Team.BOARD, + created_by=setup["creator"], + project_id=None, + product_id=setup["product_id"], + task_type=TaskType.CODE, + nature=TaskNature.NON_TECHNICAL, + estimated_complexity=Complexity.HIGH, + ) + ) + ux_cell = await svc.create_subtask( + TaskCreateRequest( + title="UX/UI design for the feature", + description="a real ux design task description over twenty chars", + acceptance_criteria=["wireframes approved"], + team=Team.UX_UI, + created_by=setup["creator"], + project_id=setup["ux_project_id"], + product_id=setup["product_id"], + parent_task_id=root.id, + task_type=TaskType.DESIGN, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.MEDIUM, + ) + ) + fe_cell = await svc.create_subtask( + TaskCreateRequest( + title="Frontend implementation for the feature", + description="a real frontend cell task description over twenty chars", + acceptance_criteria=["UI matches the design"], + team=Team.FRONTEND, + created_by=setup["creator"], + project_id=setup["fe_project_id"], + product_id=setup["product_id"], + parent_task_id=root.id, + task_type=TaskType.CODE, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.MEDIUM, + ) + ) + # Cross-cell sequencing: wire the frontend CELL task onto the UX cell task + # exactly as the product fan-out does on delegate. + await choreo._wire_ux_frontend_dependency(fe_cell, root) + await svc.session.flush() + refreshed_fe = await svc.get(fe_cell.id) + assert refreshed_fe is not None + assert ux_cell.id in refreshed_fe.dependency_ids, ( + "precondition: frontend cell task must depend on the UX cell task" + ) + return {"root": root, "ux_cell": ux_cell, "fe_cell": fe_cell} + + +@pytest.mark.asyncio +async def test_dev_subtask_held_until_ux_dependency_resolves( + fanout_setup: dict, +) -> None: + svc: TaskService = fanout_setup["svc"] + choreo: Choreographer = fanout_setup["choreo"] + tree = await _build_product_fanout(fanout_setup) + fe_cell = tree["fe_cell"] + ux_cell = tree["ux_cell"] + + # Cell PM delegates a dev/code subtask under the frontend cell task. + dev_subtask = await svc.create_subtask( + TaskCreateRequest( + title="Implement the login form component", + description="a real dev subtask description over twenty chars long", + acceptance_criteria=["form renders and submits"], + team=Team.FRONTEND, + created_by=fanout_setup["creator"], + project_id=fanout_setup["fe_project_id"], + product_id=fanout_setup["product_id"], + parent_task_id=fe_cell.id, + assigned_to=fanout_setup["fe_dev_id"], + task_type=TaskType.CODE, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.MEDIUM, + ) + ) + assert dev_subtask.status == TaskStatus.PENDING + # The delegate flow wires cross-cell sequencing on the new subtask. + await choreo._wire_ux_frontend_dependency(dev_subtask, fe_cell) + await svc.session.flush() + + # While the UX cell task is unresolved, the dev subtask must NOT be + # dispatchable (held by the dependency filter). + pending = await svc.list_pending(team=Team.FRONTEND, filter_by_dependencies=True) + pending_ids = {t.id for t in pending} + assert dev_subtask.id not in pending_ids, ( + "dev subtask must be held while the UX dependency is unresolved" + ) + + # UX finishes -> terminal state. + ux_row = await svc.get(ux_cell.id) + assert ux_row is not None + ux_row.status = TaskStatus.COMPLETED + await svc.session.flush() + + # Now the dev subtask is dispatchable. + pending_after = await svc.list_pending( + team=Team.FRONTEND, filter_by_dependencies=True + ) + pending_after_ids = {t.id for t in pending_after} + assert dev_subtask.id in pending_after_ids, ( + "dev subtask must become dispatchable once UX reaches a terminal state" + )