mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): dev subtask inherits the cell task's unmet cross-cell dependency
A frontend cell task waits on the UX/UI design, but a dev/code subtask delegated under it did not inherit the unresolved dependency, so the developer became dispatchable and coded ahead of the design. Propagate the parent cell task's still-unresolved dependencies onto the new subtask on delegate, reusing the existing dependency model: the subtask is held by list_pending(filter_by_dependencies=True) until the UX task is terminal.
This commit is contained in:
@@ -3321,7 +3321,10 @@ class Choreographer:
|
|||||||
"""Cross-cell sequencing: in a product fan-out the FRONTEND cell task
|
"""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
|
depends on the UX/UI cell task — UX design is upstream of frontend
|
||||||
implementation, while backend runs in parallel. Wires the dependency in
|
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:
|
if parent is None or getattr(parent, "product_id", None) is None:
|
||||||
return
|
return
|
||||||
@@ -3329,6 +3332,9 @@ class Choreographer:
|
|||||||
|
|
||||||
nt_team = self._team_value(new_task.team)
|
nt_team = self._team_value(new_task.team)
|
||||||
try:
|
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:
|
if nt_team == Team.FRONTEND.value:
|
||||||
await self._depend_frontend_on_ux(new_task, parent.id)
|
await self._depend_frontend_on_ux(new_task, parent.id)
|
||||||
elif nt_team == Team.UX_UI.value:
|
elif nt_team == Team.UX_UI.value:
|
||||||
|
|||||||
@@ -4192,6 +4192,32 @@ class TaskService(BaseService):
|
|||||||
task.dependency_ids = [*task.dependency_ids, depends_on_id]
|
task.dependency_ids = [*task.dependency_ids, depends_on_id]
|
||||||
await self.session.flush()
|
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]:
|
async def get_subtasks(self, parent_task_id: UUID) -> list[TaskTable]:
|
||||||
"""Get all subtasks of a parent task."""
|
"""Get all subtasks of a parent task."""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user