diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 736e78ff..8ad48368 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -5568,17 +5568,33 @@ class Choreographer: return value.value if hasattr(value, "value") else str(value) async def _wire_ux_frontend_dependency(self, new_task: Any, parent: Any) -> None: - """Cross-cell sequencing: in a product fan-out the implementation cells + """Cross-cell sequencing: in a multi-cell fan-out the implementation cells (FRONTEND and BACKEND) depend on the UX/UI cell task — UX design defines the screens and API contracts both cells build against, so it is upstream of implementation. Wires the dependency in 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. + + Fires for a product fan-out (``product_id`` set) AND a MegaTask + root-subtask (a batch item that fans out to its own cells via + ``cell_projects`` and carries no ``product_id``) — without the latter the + cross-cell edges were never wired for MegaTask cells, so they ran fully + in parallel and the sequence was ignored (divergent branches). """ - if parent is None or getattr(parent, "product_id", None) is None: + if parent is None: return from roboco.foundation.identity import Team + from roboco.foundation.policy.batch import is_batch_root_subtask + + is_fanout = getattr( + parent, "product_id", None + ) is not None or is_batch_root_subtask( + batch_id=getattr(parent, "batch_id", None), + parent_task_id=getattr(parent, "parent_task_id", None), + ) + if not is_fanout: + return nt_team = self._team_value(new_task.team) try: diff --git a/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py b/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py index 52e7b4fb..93da6e09 100644 --- a/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py +++ b/tests/integration/test_dev_subtask_inherits_cross_cell_dependency.py @@ -18,6 +18,7 @@ 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.product import ProductCellMapping from roboco.models.task import TaskCreateRequest from roboco.services.gateway.choreographer._impl import ( Choreographer, @@ -179,6 +180,93 @@ async def _build_product_fanout(setup: dict) -> dict: return {"root": root, "ux_cell": ux_cell, "fe_cell": fe_cell} +@pytest.mark.asyncio +async def test_megatask_root_wires_cross_cell_ux_dependency( + fanout_setup: dict, +) -> None: + """A MegaTask root-subtask (a batch item with NO product_id — it fans out to + cells via cell_projects) must get the same cross-cell wiring as a product + fan-out: its FRONTEND cell depends on its UX/UI cell. Regression for the + product_id-only guard that skipped MegaTask roots, so their cells ran fully + in parallel and ignored the sequence (divergent branches).""" + svc: TaskService = fanout_setup["svc"] + choreo: Choreographer = fanout_setup["choreo"] + batch_id = uuid4() + umbrella = await svc.create( + TaskCreateRequest( + title="MegaTask umbrella coordination root", + description="a real umbrella coordination task description over 20 chars", + acceptance_criteria=["batch coordination"], + team=Team.MAIN_PM, + created_by=fanout_setup["creator"], + project_id=None, + task_type=TaskType.PLANNING, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.HIGH, + batch_id=batch_id, + ) + ) + root = await svc.create( + TaskCreateRequest( + title="MegaTask root-subtask that fans out to cells", + description="a real megatask root-subtask description over twenty chars", + acceptance_criteria=["fans out to ux + frontend cells"], + team=Team.MAIN_PM, + created_by=fanout_setup["creator"], + project_id=None, # no product_id — the cell_projects model + parent_task_id=cast("UUID", umbrella.id), + batch_id=batch_id, + cell_projects=[ + ProductCellMapping( + team=Team.UX_UI, project_id=fanout_setup["ux_project_id"] + ), + ProductCellMapping( + team=Team.FRONTEND, project_id=fanout_setup["fe_project_id"] + ), + ], + task_type=TaskType.PLANNING, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.HIGH, + ) + ) + ux_cell = await svc.create_subtask( + TaskCreateRequest( + title="UX/UI design for the megatask root", + description="a real ux design task description over twenty chars", + acceptance_criteria=["wireframes approved"], + team=Team.UX_UI, + created_by=fanout_setup["creator"], + project_id=fanout_setup["ux_project_id"], + parent_task_id=cast("UUID", 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 megatask root", + description="a real frontend cell task description over twenty chars", + acceptance_criteria=["UI matches the design"], + team=Team.FRONTEND, + created_by=fanout_setup["creator"], + project_id=fanout_setup["fe_project_id"], + parent_task_id=cast("UUID", root.id), + task_type=TaskType.CODE, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.MEDIUM, + ) + ) + await choreo._wire_ux_frontend_dependency(fe_cell, root) + await svc.session.flush() + refreshed_fe = await svc.get(cast("UUID", fe_cell.id)) + assert refreshed_fe is not None + assert ux_cell.id in refreshed_fe.dependency_ids, ( + "MegaTask root frontend cell must depend on its UX cell — the product_id " + "guard previously skipped MegaTask roots, leaving their cells un-sequenced" + ) + + @pytest.mark.asyncio async def test_dev_subtask_held_until_ux_dependency_resolves( fanout_setup: dict,