mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(megatask): per-cell project map root-subtasks (multi-project, multi-cell)
A MegaTask root-subtask can now target an ad-hoc per-cell project map — a
third targeting shape that mirrors the existing product fan-out root. In
RoboCo a project is per-cell (ProjectTable.assigned_cell); a monorepo is N
per-cell projects sharing one git_url. So 'multi-cell' IS 'multi-project',
and a task may mix per-cell projects across products or include OSS-library
projects not in any product.
Storage: migration 052 adds task_cell_projects (mirrors product_projects;
unique per (task, team)). TaskTable gains a cascade-delete cell_projects
relationship; TaskCreateRequest / TaskCreate / Task response carry the map.
Policy: batch.is_branchless_coordination + is_valid_batch_shape gain a
has_cell_projects param — a root-subtask targets exactly one of project /
product / cell-map; the umbrella still targets none. TaskService passes
has_cell_projects at every predicate call site and persists the rows in
create(). _ensure_branch_for_task cuts feature/main_pm/{root} per distinct
project in the map (via _distinct_projects_for_task); _require_target_or_umbrella
and _validate_batch_membership accept the map shape.
Fan-out: every distinct_project_ids site (task.py branch creation, routes
_project_for_complete + _resolve_project_for_merge, orchestrator
_ambient_projects_for_task, pr_review._project_slug_for, git._project_for_task)
generalizes to first-distinct-project-of-map-or-product. Choreographer
_resolve_subtask_project resolves a delegated subtask's cell from the parent's
cell map. The product-scoped _slugs_for_product intake helper is unchanged.
Intake: prompter._draft_cell_map extracts the per-cell map from the_work[].
_validate_batch_scope counts distinct projects across all drafts' cells
(>=2 min stays; one 2-cell draft satisfies it). create_task_from_draft
persists cell_projects for >=2-cell drafts (project_id/product_id None),
collapses a 1-cell map to the single-project shape, and leaves single-cell
top-level project_id drafts unchanged. _resolve_owning_team routes a
multi-cell map to Main PM (coordination root, like a product root — a cell
PM can't delegate cross-cell). propose_draft/propose_batch tool descriptions
declare the per-cell project_id (both Claude SDK + grok runtimes).
The umbrella stays branchless / pure-coordination / submit_root-rejected;
the CEO-escalation pr_number gate is not widened (the map root is
is_umbrella=False, mirroring a product root, so submit_root supplies it).
Single-cell root-subtasks and everything below them are byte-for-byte
unchanged. Un-run MegaTask waves (multi-cell drafts) become runnable.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""Add the task_cell_projects table — ad-hoc per-cell project map for a task.
|
||||
|
||||
A MegaTask root-subtask that spans multiple cells (and may mix per-cell projects
|
||||
from different products / OSS libs) needs a per-cell routing map without standing
|
||||
up a Product for it. ``task_cell_projects`` mirrors ``product_projects`` but is
|
||||
owned by the task: one Project per cell per task (``UNIQUE (task_id, team)``). The
|
||||
root-subtask then cuts ``feature/main_pm/{root}`` per repo and opens a root->master
|
||||
PR per repo exactly like a Product fan-out root — only the map's source differs.
|
||||
``team`` reuses the existing Postgres "team" enum (create_type=False).
|
||||
|
||||
Revision ID: 052_task_cell_projects
|
||||
Revises: 051_respawn_tracker
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "052_task_cell_projects"
|
||||
down_revision = "051_respawn_tracker"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Reuse the existing Postgres "team" enum in place (created in 001_initial_schema,
|
||||
# widened since by later migrations); create_type=False so this migration never
|
||||
# tries to (re)create it.
|
||||
_TEAM_ENUM = sa.Enum(
|
||||
"backend",
|
||||
"frontend",
|
||||
"ux_ui",
|
||||
"board",
|
||||
"main_pm",
|
||||
"fullstack",
|
||||
"marketing",
|
||||
"system",
|
||||
name="team",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task_cell_projects",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"task_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("team", _TEAM_ENUM, nullable=False),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("projects.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.UniqueConstraint("task_id", "team", name="uq_task_cell_projects_task_team"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("task_cell_projects")
|
||||
@@ -391,9 +391,11 @@ def build_intake_options(
|
||||
"propose_draft",
|
||||
"Submit the finished task draft for the human to review and confirm. Call "
|
||||
"this once the spec is complete. Pass a JSON object: title, objective, "
|
||||
"what_this_builds[], the_work[] ({team, summary, items}), notes[], "
|
||||
"acceptance_criteria[], team, scale, task_type, nature, "
|
||||
"estimated_complexity, priority.",
|
||||
"what_this_builds[], the_work[] ({team, summary, items, project_id}), "
|
||||
"notes[], acceptance_criteria[], team, scale, task_type, nature, "
|
||||
"estimated_complexity, priority. A multi-cell task (be+fe, fe+uxui) puts "
|
||||
"one entry per cell in the_work, each with its cell's project_id (the "
|
||||
"per-cell repo); the system builds the cell->project map from them.",
|
||||
{"draft": dict},
|
||||
)
|
||||
async def _propose_draft(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -411,9 +413,11 @@ def build_intake_options(
|
||||
"and confirm together. Use this (instead of propose_draft) when the CEO "
|
||||
"asked for multiple tasks across the scoped repos. Pass {drafts: [draft, "
|
||||
"...], title: '...'} where each draft has the same fields as propose_draft "
|
||||
"PLUS its own project_id (which repo it targets) and collision surface "
|
||||
"(intends_to_touch[], adds_migration, touches_shared) so the system can "
|
||||
"sequence them into conflict-free waves.",
|
||||
"PLUS a collision surface (intends_to_touch[], adds_migration, "
|
||||
"touches_shared) so the system can sequence them into conflict-free waves. "
|
||||
"Each draft targets its repos via the per-cell project_id on its the_work[] "
|
||||
"entries (a multi-cell task has one project_id per cell; a single-cell task "
|
||||
"may use one the_work entry or a top-level project_id).",
|
||||
{"drafts": list, "title": str},
|
||||
)
|
||||
async def _propose_batch(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -185,6 +185,24 @@ async def _resolve_assigned_to_slug(
|
||||
return data.model_copy(update={"assigned_to": str(agent_row.id)})
|
||||
|
||||
|
||||
def _first_cell_map_project_id(task: Any) -> UUID | None:
|
||||
"""First distinct project_id from a task's ad-hoc per-cell map.
|
||||
|
||||
Mirrors the product-root ``distinct_project_ids(...)[0]`` first-project
|
||||
resolution: dedupes by project_id (a monorepo mapped across cells shares
|
||||
one project), ordered by cell team for determinism. Returns None when the
|
||||
task carries no cell map.
|
||||
"""
|
||||
cell_map = getattr(task, "cell_projects", None) or []
|
||||
seen: set[UUID] = set()
|
||||
for mapping in sorted(cell_map, key=lambda m: m.team.value):
|
||||
pid = UUID(str(mapping.project_id))
|
||||
if pid not in seen:
|
||||
seen.add(pid)
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
|
||||
"""Resolve the project for complete_task's pre-merge step.
|
||||
|
||||
@@ -203,6 +221,9 @@ async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
|
||||
pids = await product_service.distinct_project_ids(UUID(str(task.product_id)))
|
||||
if pids:
|
||||
return await project_service.get(pids[0])
|
||||
cell_pid = _first_cell_map_project_id(task)
|
||||
if cell_pid is not None:
|
||||
return await project_service.get(cell_pid)
|
||||
return None
|
||||
|
||||
|
||||
@@ -279,13 +300,17 @@ async def _resolve_project_for_merge(task: Any, db: AsyncSession) -> Any:
|
||||
)
|
||||
resolved_id = project_ids[0]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"NO_PROJECT: Task has neither project_id nor product_id; "
|
||||
"cannot resolve workspace for merge. Set project_id on the task first."
|
||||
),
|
||||
)
|
||||
cell_pid = _first_cell_map_project_id(task)
|
||||
if cell_pid is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"NO_PROJECT: Task has neither project_id, product_id, nor a "
|
||||
"cell->project map; cannot resolve workspace for merge. Set a "
|
||||
"target on the task first."
|
||||
),
|
||||
)
|
||||
resolved_id = cell_pid
|
||||
project = await project_service.get(resolved_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import ProjectTable, TaskTable, WorkSessionTable
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||
from roboco.models.product import ProductCellMapping
|
||||
from roboco.models.session import SessionScope
|
||||
from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list
|
||||
|
||||
@@ -296,6 +297,9 @@ class TaskResponse(BaseModel):
|
||||
project_id: UUID | None = None # Repo this task targets (None for fan-out)
|
||||
project_slug: str | None = None # Project slug for MCP/git tool calls
|
||||
product_id: UUID | None = None
|
||||
# Ad-hoc per-cell project map (a multi-cell MegaTask root-subtask); empty for
|
||||
# a project/product-targeted task.
|
||||
cell_projects: list[ProductCellMapping] = []
|
||||
|
||||
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
|
||||
docs_complete: bool = False # Documenter has finished
|
||||
@@ -666,6 +670,24 @@ def convert_commits(commits_data: list | None) -> list[CommitRefResponse]:
|
||||
]
|
||||
|
||||
|
||||
def convert_cell_projects(task: "TaskTable") -> list[ProductCellMapping]:
|
||||
"""The task's ad-hoc per-cell project map, or ``[]``.
|
||||
|
||||
Skips the access when the ``cell_projects`` relationship is unloaded (a
|
||||
freshly-created task not yet re-queried) to avoid a sync lazy-load
|
||||
``MissingGreenlet`` — mirrors the ``project_slug`` guard. selectin loading
|
||||
means a normally-queried task already carries the map.
|
||||
"""
|
||||
if "cell_projects" in sa_inspect(task).unloaded:
|
||||
return []
|
||||
return [
|
||||
ProductCellMapping(
|
||||
team=mapping.team, project_id=require_uuid(mapping.project_id)
|
||||
)
|
||||
for mapping in task.cell_projects
|
||||
]
|
||||
|
||||
|
||||
def task_to_response(task: "TaskTable") -> TaskResponse:
|
||||
"""Convert TaskTable to TaskResponse with proper UUID conversion."""
|
||||
return TaskResponse(
|
||||
@@ -698,6 +720,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
|
||||
dependency_ids=to_python_uuid_list(task.dependency_ids),
|
||||
blocker_ids=to_python_uuid_list(task.blocker_ids),
|
||||
batch_id=to_python_uuid(task.batch_id),
|
||||
cell_projects=convert_cell_projects(task),
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
claimed_at=task.claimed_at,
|
||||
|
||||
@@ -444,6 +444,18 @@ class TaskTable(Base):
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
# Per-cell project map for an ad-hoc (non-Product) coordination root —
|
||||
# mirrors product_projects but is owned by the task itself, so a MegaTask
|
||||
# root-subtask can target a per-cell map mixing projects from different
|
||||
# products / OSS libs. selectin so the fan-out resolvers see the map on
|
||||
# any task fetch; passive_deletes trusts the ON DELETE CASCADE FK.
|
||||
cell_projects: Mapped[list["TaskCellProjectTable"]] = relationship(
|
||||
"TaskCellProjectTable",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Composite indexes for common queries
|
||||
@@ -617,6 +629,50 @@ class ProductProjectTable(Base):
|
||||
)
|
||||
|
||||
|
||||
class TaskCellProjectTable(Base):
|
||||
"""One Project per cell for an ad-hoc (non-Product) coordination root.
|
||||
|
||||
Mirrors ``product_projects`` but the map is owned by the task, not a Product:
|
||||
a MegaTask root-subtask that spans multiple cells (and may mix per-cell
|
||||
projects from different products / OSS libs) carries its per-cell routing
|
||||
here instead of a ``product_id``. The root itself does git per repo (it cuts
|
||||
``feature/main_pm/{root}`` and opens a root->master PR per repo exactly like a
|
||||
Product fan-out root); the cell children each resolve their project from this
|
||||
map via ``_resolve_subtask_project``. ``UNIQUE (task_id, team)`` enforces one
|
||||
project per cell per task.
|
||||
"""
|
||||
|
||||
__tablename__ = "task_cell_projects"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
task_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
team: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
||||
project_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
task: Mapped["TaskTable"] = relationship(
|
||||
"TaskTable", back_populates="cell_projects"
|
||||
)
|
||||
project: Mapped["ProjectTable"] = relationship(
|
||||
"ProjectTable", foreign_keys=[project_id], lazy="joined"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "team", name="uq_task_cell_projects_task_team"),
|
||||
)
|
||||
|
||||
|
||||
class PitchTable(Base):
|
||||
"""A Board proposal the CEO approves to auto-provision a product.
|
||||
|
||||
|
||||
@@ -41,20 +41,28 @@ def is_branchless_coordination(
|
||||
product_id: object | None,
|
||||
batch_id: object | None = None,
|
||||
parent_task_id: object | None = None,
|
||||
has_cell_projects: bool = False,
|
||||
) -> bool:
|
||||
"""True for a task that does no git of its own (no branch, no PR).
|
||||
|
||||
Two shapes qualify: a product fan-out coordination root (no ``project_id``,
|
||||
carries a ``product_id``), and a MegaTask umbrella (``batch_id`` set,
|
||||
top-level). Both are Main-PM coordination points whose children do the git.
|
||||
Three shapes qualify: a product fan-out coordination root (no ``project_id``,
|
||||
carries a ``product_id``), an ad-hoc per-cell map coordination root (no
|
||||
``project_id``, no ``product_id``, carries a ``task_cell_projects`` map), and a
|
||||
MegaTask umbrella (``batch_id`` set, top-level). All three are Main-PM
|
||||
coordination points whose children do the git — the map/product root still
|
||||
cuts ``feature/main_pm/{root}`` per repo and opens a root->master PR per repo,
|
||||
but the *claim branch gate* skips the single-branch requirement for it exactly
|
||||
as it does for a product root.
|
||||
|
||||
Relies on the creation-time invariant ``is_valid_batch_shape`` that a
|
||||
``batch_id``-bearing top-level task carries no project/product — so a real
|
||||
``batch_id``-bearing top-level task carries no project/product/map — so a real
|
||||
umbrella is genuinely branchless and a normal task cannot spoof the exemption
|
||||
by attaching a ``batch_id``.
|
||||
"""
|
||||
if project_id is None and product_id is not None:
|
||||
return True
|
||||
if project_id is None and product_id is None and has_cell_projects:
|
||||
return True
|
||||
return is_batch_umbrella(batch_id=batch_id, parent_task_id=parent_task_id)
|
||||
|
||||
|
||||
@@ -64,16 +72,17 @@ def is_valid_batch_shape(
|
||||
parent_task_id: object | None,
|
||||
project_id: object | None,
|
||||
product_id: object | None,
|
||||
has_cell_projects: bool = False,
|
||||
) -> bool:
|
||||
"""Guardrail: a ``batch_id`` is only valid on a well-formed MegaTask member.
|
||||
|
||||
A ``batch_id`` is permitted on exactly two shapes:
|
||||
|
||||
- an **umbrella** (no ``parent_task_id``) — which must target NEITHER a
|
||||
project nor a product (it is branchless, grouping root-subtasks that each
|
||||
carry their own repo);
|
||||
project, a product, nor carry a cell map (it is branchless, grouping
|
||||
root-subtasks that each carry their own repo);
|
||||
- a **root-subtask** (has a ``parent_task_id``) — which must target exactly
|
||||
one of project / product (it does its own git).
|
||||
one of project / product / ad-hoc cell map (it does its own git).
|
||||
|
||||
A task without a ``batch_id`` is unconstrained here (the normal targeting
|
||||
rule applies). Denying every other ``batch_id`` shape stops a normal task
|
||||
@@ -83,7 +92,8 @@ def is_valid_batch_shape(
|
||||
"""
|
||||
if batch_id is None:
|
||||
return True
|
||||
targets = bool(project_id) + bool(product_id) + bool(has_cell_projects)
|
||||
if parent_task_id is None: # umbrella
|
||||
return project_id is None and product_id is None
|
||||
return targets == 0
|
||||
# root-subtask: exactly one target
|
||||
return (project_id is None) != (product_id is None)
|
||||
return targets == 1
|
||||
|
||||
@@ -96,9 +96,11 @@ async def propose_draft(draft: dict[str, Any]) -> str:
|
||||
"""Submit the finished task draft for the human to review and confirm.
|
||||
|
||||
Call this once the spec is complete. Pass a JSON object: title, objective,
|
||||
what_this_builds[], the_work[] ({team, summary, items}), notes[],
|
||||
what_this_builds[], the_work[] ({team, summary, items, project_id}), notes[],
|
||||
acceptance_criteria[], team, scale, task_type, nature, estimated_complexity,
|
||||
priority.
|
||||
priority. A multi-cell task (be+fe, fe+uxui) puts one entry per cell in
|
||||
the_work, each with its cell's project_id (the per-cell repo); the system
|
||||
builds the cell->project map from them.
|
||||
|
||||
Sequenced batch intake (when enabled): if the CEO asks for several tasks at
|
||||
once, propose one draft per item and set each item's collision surface so the
|
||||
@@ -127,12 +129,14 @@ async def propose_batch(drafts: list[dict[str, Any]], title: str = "") -> str:
|
||||
|
||||
Use this instead of ``propose_draft`` when the CEO asked for multiple tasks
|
||||
across the scoped repos. ``drafts`` is a list where each item has the same
|
||||
fields as a single draft PLUS its own ``project_id`` (which repo it targets)
|
||||
and collision surface — ``intends_to_touch[]`` (files/dirs it will modify),
|
||||
``adds_migration`` (adds a DB migration?), ``touches_shared`` (edits a widely
|
||||
shared component?). The system sequences them into conflict-free waves;
|
||||
over-declaring a surface is safer than under-declaring. ``title`` names the
|
||||
MegaTask.
|
||||
fields as a single draft PLUS a collision surface — ``intends_to_touch[]``
|
||||
(files/dirs it will modify), ``adds_migration`` (adds a DB migration?),
|
||||
``touches_shared`` (edits a widely shared component?). The system sequences
|
||||
them into conflict-free waves; over-declaring a surface is safer than
|
||||
under-declaring. ``title`` names the MegaTask. Each draft targets its repos
|
||||
via the per-cell ``project_id`` on its ``the_work[]`` entries (a multi-cell
|
||||
task has one project_id per cell; a single-cell task may use one the_work
|
||||
entry or a top-level ``project_id``).
|
||||
"""
|
||||
session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
|
||||
if not session_id:
|
||||
|
||||
+30
-6
@@ -21,6 +21,7 @@ from roboco.models.base import (
|
||||
Team,
|
||||
TimestampMixin,
|
||||
)
|
||||
from roboco.models.product import ProductCellMapping
|
||||
|
||||
# =============================================================================
|
||||
# SUPPORTING MODELS
|
||||
@@ -167,6 +168,13 @@ class Task(TimestampMixin):
|
||||
default=None,
|
||||
description="Product this task belongs to (additive; drives subtask routing)",
|
||||
)
|
||||
cell_projects: list[ProductCellMapping] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Ad-hoc per-cell project map (a MegaTask root-subtask spanning "
|
||||
"multiple cells); empty for a project/product-targeted task"
|
||||
),
|
||||
)
|
||||
branch_name: str | None = Field(
|
||||
default=None, description="Branch created for this task"
|
||||
)
|
||||
@@ -373,21 +381,31 @@ class TaskCreate(RobocoBase):
|
||||
task_type: TaskType = Field(...)
|
||||
nature: TaskNature = Field(...)
|
||||
# A task targets a single repo (project_id) OR fans out across cells via a
|
||||
# product (product_id, a cell->project map). Exactly one is needed; a
|
||||
# board/coordination task uses product_id and has no project of its own.
|
||||
# product (product_id, a cell->project map) OR carries an ad-hoc per-cell
|
||||
# map (cell_projects — a MegaTask root-subtask spanning multiple cells).
|
||||
# Exactly one is needed; a board/coordination task uses product_id and has
|
||||
# no project of its own.
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
cell_projects: list[ProductCellMapping] = Field(default_factory=list)
|
||||
|
||||
# Prompter origin tracking
|
||||
source: str = Field(default="manual")
|
||||
confirmed_by_human: bool = Field(default=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _project_or_product(self) -> "TaskCreate":
|
||||
if self.project_id is None and self.product_id is None:
|
||||
def _exactly_one_target(self) -> "TaskCreate":
|
||||
targets = (
|
||||
self.project_id is not None,
|
||||
self.product_id is not None,
|
||||
bool(self.cell_projects),
|
||||
)
|
||||
if sum(targets) != 1:
|
||||
raise ValueError(
|
||||
"a task needs either a project_id (the repo it targets) or a "
|
||||
"product_id (a cell->project map for a fan-out task)"
|
||||
"a task needs exactly one target: a project_id (the repo it "
|
||||
"targets), a product_id (a cell->project map for a fan-out "
|
||||
"task), or cell_projects (an ad-hoc per-cell map for a "
|
||||
"multi-cell coordination root)"
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -456,6 +474,12 @@ class TaskCreateRequest:
|
||||
# cells' subtasks resolve their own project from it.
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
# Ad-hoc per-cell project map (mirrors a Product's cells but owned by the
|
||||
# task): a MegaTask root-subtask spanning multiple cells (and possibly mixing
|
||||
# per-cell projects from different products / OSS libs) sets this instead of
|
||||
# project_id/product_id. The root cuts feature/main_pm/{root} per repo and
|
||||
# opens a root->master PR per repo exactly like a Product fan-out root.
|
||||
cell_projects: list[ProductCellMapping] = field(default_factory=list)
|
||||
|
||||
# Ordering and dependencies
|
||||
sequence: int = 0 # Order within siblings (lower = first)
|
||||
|
||||
@@ -315,19 +315,23 @@ def _read_project_slug(task: dict[str, Any]) -> str | None:
|
||||
def _is_coordination_task(task: dict[str, Any]) -> bool:
|
||||
"""True for a task that does no git of its own.
|
||||
|
||||
Two shapes qualify: a board/fan-out coordination root (carries a product, no
|
||||
repo — its cell subtasks resolve a real project from the product's
|
||||
cell->project map), and a MegaTask umbrella (carries a batch_id, top-level —
|
||||
its root-subtasks each carry their own branch/PR). Such a task has no
|
||||
Three shapes qualify: a board/fan-out coordination root (carries a product,
|
||||
no repo — its cell subtasks resolve a real project from the product's
|
||||
cell->project map), an ad-hoc per-cell map coordination root (carries a
|
||||
``cell_projects`` map but no project/product — a multi-cell MegaTask
|
||||
root-subtask), and a MegaTask umbrella (carries a batch_id, top-level — its
|
||||
root-subtasks each carry their own branch/PR). Such a task has no
|
||||
project_slug, branch_name, or git token, and must NOT be git-gated at the
|
||||
spawn-readiness or stuck-detection checks the way a code task is. A task with
|
||||
none of project / product / batch is genuinely unroutable and stays gated.
|
||||
none of project / product / cell-map / batch is genuinely unroutable and
|
||||
stays gated.
|
||||
"""
|
||||
return is_branchless_coordination(
|
||||
project_id=task.get("project_id"),
|
||||
product_id=task.get("product_id"),
|
||||
batch_id=task.get("batch_id"),
|
||||
parent_task_id=task.get("parent_task_id"),
|
||||
has_cell_projects=bool(task.get("cell_projects")),
|
||||
)
|
||||
|
||||
|
||||
@@ -2640,11 +2644,14 @@ class AgentOrchestrator:
|
||||
task_id: str | None,
|
||||
product_id: str | None,
|
||||
) -> list[Any]:
|
||||
"""The in-scope projects for the ambient block (single repo or product)."""
|
||||
if product_id is None and task_id is not None:
|
||||
product_id = await self._ambient_product_for_task(db, task_id)
|
||||
"""The in-scope projects for the ambient block (single repo, product, or
|
||||
ad-hoc cell map)."""
|
||||
if product_id is not None:
|
||||
return await self._ambient_product_projects(db, product_id)
|
||||
if task_id is not None:
|
||||
projects = await self._ambient_projects_for_task(db, task_id)
|
||||
if projects:
|
||||
return projects
|
||||
if project_slug:
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
@@ -2653,15 +2660,35 @@ class AgentOrchestrator:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def _ambient_product_for_task(db: Any, task_id: str) -> str | None:
|
||||
async def _ambient_projects_for_task(db: Any, task_id: str) -> list[Any]:
|
||||
"""The in-scope projects for a task's ambient block, from its product OR
|
||||
its ad-hoc ``cell_projects`` map. Empty for a plain project task (the
|
||||
project_slug branch handles those) or a not-yet-mapped coordination root.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
task = await get_task_service(db).get(UUID(task_id))
|
||||
if task is not None and task.product_id is not None:
|
||||
return str(task.product_id)
|
||||
return None
|
||||
if task is None:
|
||||
return []
|
||||
project_service = get_project_service(db)
|
||||
if task.product_id is not None:
|
||||
from roboco.services.product import get_product_service
|
||||
|
||||
ids = await get_product_service(db).distinct_project_ids(
|
||||
UUID(str(task.product_id))
|
||||
)
|
||||
resolved = [await project_service.get(pid) for pid in ids]
|
||||
return [p for p in resolved if p is not None]
|
||||
# Ad-hoc per-cell map: resolve the distinct projects the map spans (de-dupe
|
||||
# by project_id — a monorepo mapped across cells yields one project).
|
||||
distinct_ids: dict[Any, None] = {}
|
||||
for mapping in sorted(task.cell_projects, key=lambda m: m.team.value):
|
||||
distinct_ids.setdefault(UUID(str(mapping.project_id)), None)
|
||||
resolved = [await project_service.get(pid) for pid in distinct_ids]
|
||||
return [p for p in resolved if p is not None]
|
||||
|
||||
@staticmethod
|
||||
async def _ambient_product_projects(db: Any, product_id: str) -> list[Any]:
|
||||
@@ -6808,13 +6835,13 @@ Start by:
|
||||
return (
|
||||
f"Task {task_id} has inadequate description ({len(description)} chars)"
|
||||
)
|
||||
# A coordination task carries a product instead of a repo; only a task
|
||||
# with neither is genuinely unroutable.
|
||||
# A coordination task carries a product or an ad-hoc cell map instead of a
|
||||
# repo; only a task with neither is genuinely unroutable.
|
||||
if not task.get("project_id") and not _is_coordination_task(task):
|
||||
await self._auto_block_task(
|
||||
client, task_id, "Task needs a project_id or product_id"
|
||||
client, task_id, "Task needs a project_id, product_id, or cell map"
|
||||
)
|
||||
return f"Task {task_id} needs a project or product"
|
||||
return f"Task {task_id} needs a project, product, or cell map"
|
||||
return None
|
||||
|
||||
async def _check_dependencies_terminal(
|
||||
|
||||
@@ -4709,13 +4709,21 @@ class Choreographer:
|
||||
) -> UUID:
|
||||
"""Resolve the project a delegated subtask lands in.
|
||||
|
||||
Priority: explicit inputs.project_id -> the parent's Product map for
|
||||
this cell -> the parent's own project. Raises TaskCompletenessError only
|
||||
for a fan-out parent (product, no own project) whose product has no
|
||||
mapping for this cell — i.e. the subtask would have no repo to land in.
|
||||
Priority: explicit inputs.project_id -> the parent's ad-hoc cell_projects
|
||||
map for this cell -> the parent's Product map for this cell -> the
|
||||
parent's own project. Raises TaskCompletenessError only for a fan-out
|
||||
parent (product or cell map, no own project) whose map has no mapping for
|
||||
this cell — i.e. the subtask would have no repo to land in.
|
||||
"""
|
||||
if inputs.project_id is not None:
|
||||
return inputs.project_id
|
||||
# Ad-hoc per-cell map (a multi-cell MegaTask root-subtask): mirror the
|
||||
# product.project_for lookup but read the map off the parent task itself.
|
||||
parent_cell_map = getattr(parent, "cell_projects", None)
|
||||
if parent_cell_map:
|
||||
for mapping in parent_cell_map:
|
||||
if mapping.team == inputs.team:
|
||||
return UUID(str(mapping.project_id))
|
||||
parent_product_id = getattr(parent, "product_id", None)
|
||||
if self.product is not None and parent_product_id is not None:
|
||||
mapped = await self.product.project_for(parent_product_id, inputs.team)
|
||||
@@ -4730,8 +4738,8 @@ class Choreographer:
|
||||
field_hints={
|
||||
"project_id": (
|
||||
f"no project for team {inputs.team!r}: add a "
|
||||
f"{inputs.team}->project mapping to the parent's product, or "
|
||||
"pass an explicit project_id on delegate"
|
||||
f"{inputs.team}->project mapping to the parent's cell map or "
|
||||
"product, or pass an explicit project_id on delegate"
|
||||
)
|
||||
},
|
||||
message=f"cannot resolve a project for the {inputs.team} subtask",
|
||||
|
||||
@@ -476,13 +476,25 @@ class PRReviewerMixin(_Base):
|
||||
project = await project_service.get(t.project_id)
|
||||
return project.slug if project is not None else None
|
||||
product_id = getattr(t, "product_id", None)
|
||||
if product_id is None:
|
||||
return None
|
||||
from roboco.services.product import get_product_service
|
||||
if product_id is not None:
|
||||
from roboco.services.product import get_product_service
|
||||
|
||||
product_service = get_product_service(self.task.session)
|
||||
project_ids = await product_service.distinct_project_ids(UUID(str(product_id)))
|
||||
if not project_ids:
|
||||
return None
|
||||
project = await project_service.get(project_ids[0])
|
||||
return project.slug if project is not None else None
|
||||
product_service = get_product_service(self.task.session)
|
||||
project_ids = await product_service.distinct_project_ids(
|
||||
UUID(str(product_id))
|
||||
)
|
||||
if not project_ids:
|
||||
return None
|
||||
project = await project_service.get(project_ids[0])
|
||||
return project.slug if project is not None else None
|
||||
# Ad-hoc per-cell map root-subtask: mirror the product root's first-project
|
||||
# resolution so the gate verdict reaches the PR in the mapped repo.
|
||||
cell_map = getattr(t, "cell_projects", None) or []
|
||||
seen: set[UUID] = set()
|
||||
for mapping in sorted(cell_map, key=lambda m: m.team.value):
|
||||
pid = UUID(str(mapping.project_id))
|
||||
if pid not in seen:
|
||||
seen.add(pid)
|
||||
project = await project_service.get(pid)
|
||||
return project.slug if project is not None else None
|
||||
return None
|
||||
|
||||
+19
-8
@@ -2965,15 +2965,26 @@ class GitService(BaseService):
|
||||
if task.project_id is not None:
|
||||
return await project_service.get(UUID(str(task.project_id)))
|
||||
product_id = getattr(task, "product_id", None)
|
||||
if product_id is None:
|
||||
return None
|
||||
from roboco.services.product import get_product_service
|
||||
if product_id is not None:
|
||||
from roboco.services.product import get_product_service
|
||||
|
||||
product_service = get_product_service(self.session)
|
||||
project_ids = await product_service.distinct_project_ids(UUID(str(product_id)))
|
||||
if not project_ids:
|
||||
return None
|
||||
return await project_service.get(project_ids[0])
|
||||
product_service = get_product_service(self.session)
|
||||
project_ids = await product_service.distinct_project_ids(
|
||||
UUID(str(product_id))
|
||||
)
|
||||
if not project_ids:
|
||||
return None
|
||||
return await project_service.get(project_ids[0])
|
||||
# Ad-hoc per-cell map root-subtask: mirror the product root's first-project
|
||||
# resolution so root-level git ops resolve a workspace for the mapped repo.
|
||||
cell_map = getattr(task, "cell_projects", None) or []
|
||||
seen: set[UUID] = set()
|
||||
for mapping in sorted(cell_map, key=lambda m: m.team.value):
|
||||
pid = UUID(str(mapping.project_id))
|
||||
if pid not in seen:
|
||||
seen.add(pid)
|
||||
return await project_service.get(pid)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fast_gate_commands(project: Any) -> list[tuple[str, str]]:
|
||||
|
||||
+117
-22
@@ -31,6 +31,7 @@ from roboco.models.base import (
|
||||
TaskType,
|
||||
Team,
|
||||
)
|
||||
from roboco.models.product import ProductCellMapping
|
||||
from roboco.models.task import TaskCreateRequest
|
||||
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
||||
|
||||
@@ -60,6 +61,11 @@ _CELL_CAPACITY: dict[str, int] = {
|
||||
# single-repo batch, which is just an ordinary (multi-)task, not a MegaTask.
|
||||
_MIN_MEGATASK_PROJECTS = 2
|
||||
|
||||
# A draft whose per-cell map covers at least this many cells targets the ad-hoc
|
||||
# multi-cell shape (a root-subtask with a cell->project map, no single project).
|
||||
# Below it, a 1-cell map collapses to the single-project shape.
|
||||
_MULTI_CELL_MIN = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadinessTag:
|
||||
@@ -121,23 +127,33 @@ class PrompterService:
|
||||
product_id: UUID | None,
|
||||
*,
|
||||
is_umbrella: bool,
|
||||
has_cell_projects: bool = False,
|
||||
) -> None:
|
||||
"""A draft targets exactly one of project / product — or neither when it
|
||||
is a MegaTask umbrella (branchless; its root-subtasks carry the projects).
|
||||
"""A draft targets exactly one of project / product / per-cell map — or
|
||||
none when it is a MegaTask umbrella (branchless; its root-subtasks carry
|
||||
the projects). The per-cell map is the multi-cell ad-hoc shape (a
|
||||
root-subtask mixing per-cell projects from different products / OSS libs).
|
||||
"""
|
||||
if project_id is None and product_id is None and not is_umbrella:
|
||||
targets = bool(project_id) + bool(product_id) + bool(has_cell_projects)
|
||||
if is_umbrella:
|
||||
if targets != 0:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"A MegaTask umbrella targets neither project nor product "
|
||||
"(it is branchless); its root-subtasks carry the projects."
|
||||
),
|
||||
field="project_id",
|
||||
)
|
||||
return
|
||||
if targets != 1:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"The draft must target a project (single-cell) or a product "
|
||||
"(board-led, multi-cell). Pick one in the confirm step."
|
||||
"The draft must target exactly one of a project (single-cell), "
|
||||
"a product (board-led, multi-cell), or a per-cell project map "
|
||||
"(ad-hoc multi-cell). Pick one in the confirm step."
|
||||
),
|
||||
field="project_id",
|
||||
)
|
||||
if project_id is not None and product_id is not None:
|
||||
raise ValidationError(
|
||||
message="Set exactly one of project_id or product_id, not both.",
|
||||
field="product_id",
|
||||
)
|
||||
|
||||
async def _resolve_owning_team(
|
||||
self,
|
||||
@@ -152,16 +168,22 @@ class PrompterService:
|
||||
|
||||
``team_override`` pins the team for a MegaTask batch (umbrella + every
|
||||
root-subtask share one owner). Otherwise: a project target is a
|
||||
single-cell executable owned by the lead cell; a product target is a
|
||||
board-led coordination root whose team follows the start mode (encoded in
|
||||
the assignee) — the "Board review & Start" path assigns a board reviewer,
|
||||
so it stays team=board until approved (else the CEO's Approve & Start
|
||||
gate, which keys on team=board, never appears and the task strands).
|
||||
"Approve & Start" (assignee main-pm) and the post-approval state are
|
||||
team=main_pm.
|
||||
single-cell executable owned by the lead cell; a product target OR an
|
||||
ad-hoc per-cell map (≥2 cells in ``the_work``) is a multi-cell
|
||||
coordination root owned by the Main PM — the cell map mirrors a product
|
||||
fan-out root, so a cell PM (which can only delegate within its own cell)
|
||||
must NOT own it (that would deadlock on the cross-cell fan-out). A
|
||||
product's team follows the start mode (encoded in the assignee) — the
|
||||
"Board review & Start" path assigns a board reviewer, so it stays
|
||||
team=board until approved (else the CEO's Approve & Start gate, which
|
||||
keys on team=board, never appears and the task strands). "Approve &
|
||||
Start" (assignee main-pm) and the post-approval state are team=main_pm.
|
||||
"""
|
||||
if team_override is not None:
|
||||
return team_override
|
||||
if len(_draft_cell_map(draft_data)) >= _MULTI_CELL_MIN:
|
||||
# Ad-hoc multi-cell map → coordination root, like a product root.
|
||||
return Team.MAIN_PM
|
||||
if resolved_product_id is None:
|
||||
return self._lead_cell_team(draft_data, default=default_lead)
|
||||
if resolved_assigned_to is not None and await self._assignee_is_board(
|
||||
@@ -219,12 +241,27 @@ class PrompterService:
|
||||
|
||||
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
|
||||
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
|
||||
# The ad-hoc per-cell map: ≥2 cells → multi-cell root-subtask (cell map
|
||||
# shape, no project/product); 1 cell → single-project (use that project);
|
||||
# 0 → fall back to the top-level project_id (single-cell legacy).
|
||||
cell_map = _draft_cell_map(draft_data)
|
||||
cell_projects: list[ProductCellMapping] = []
|
||||
if len(cell_map) >= _MULTI_CELL_MIN:
|
||||
cell_projects = [
|
||||
ProductCellMapping(team=team, project_id=pid) for team, pid in cell_map
|
||||
]
|
||||
resolved_project_id = None
|
||||
resolved_product_id = None
|
||||
elif len(cell_map) == 1:
|
||||
resolved_project_id = cell_map[0][1]
|
||||
resolved_product_id = None
|
||||
self._validate_draft_target(
|
||||
resolved_project_id,
|
||||
resolved_product_id,
|
||||
is_umbrella=is_batch_umbrella(
|
||||
batch_id=place.batch_id, parent_task_id=place.parent_task_id
|
||||
),
|
||||
has_cell_projects=bool(cell_projects),
|
||||
)
|
||||
|
||||
_lead, task_type, nature, complexity = self._coerce_draft_enums(draft_data)
|
||||
@@ -258,6 +295,7 @@ class PrompterService:
|
||||
assigned_to=resolved_assigned_to,
|
||||
project_id=resolved_project_id,
|
||||
product_id=resolved_product_id,
|
||||
cell_projects=cell_projects,
|
||||
status=status,
|
||||
parent_task_id=place.parent_task_id,
|
||||
batch_id=place.batch_id,
|
||||
@@ -387,20 +425,41 @@ class PrompterService:
|
||||
"""A MegaTask's drafts must each target one of the scoped repos (the only
|
||||
ones the intake agent read), and collectively span at least two distinct
|
||||
projects — otherwise it's a single-repo batch, not a MegaTask.
|
||||
|
||||
Each draft targets its repos via its per-cell ``the_work[].project_id``
|
||||
map (a multi-cell draft) or, falling back, its top-level ``project_id``
|
||||
(a single-cell draft). Every targeted project must be in scope, and the
|
||||
union across all drafts' cells must clear ``_MIN_MEGATASK_PROJECTS``
|
||||
(a single 2-cell draft already satisfies it).
|
||||
"""
|
||||
scope = {str(p) for p in project_ids}
|
||||
seen: set[str] = set()
|
||||
for idx, draft in enumerate(drafts):
|
||||
pid = draft.get("project_id")
|
||||
if not pid or str(pid) not in scope:
|
||||
cell_map = _draft_cell_map(draft)
|
||||
top_pid = draft.get("project_id")
|
||||
if cell_map:
|
||||
draft_pids = [str(pid) for _, pid in cell_map]
|
||||
elif top_pid:
|
||||
draft_pids = [str(top_pid)]
|
||||
else:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Task {idx + 1} targets a project outside this MegaTask's "
|
||||
"selected repos. Point it at one of the scoped projects."
|
||||
f"Task {idx + 1} has no project. Point each of its cells at "
|
||||
"one of the scoped projects."
|
||||
),
|
||||
field="drafts",
|
||||
)
|
||||
seen.add(str(pid))
|
||||
for pid in draft_pids:
|
||||
if pid not in scope:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Task {idx + 1} targets a project outside this "
|
||||
"MegaTask's selected repos. Point it at one of the scoped "
|
||||
"projects."
|
||||
),
|
||||
field="drafts",
|
||||
)
|
||||
seen.add(pid)
|
||||
if len(seen) < _MIN_MEGATASK_PROJECTS:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
@@ -732,6 +791,42 @@ def _cell_teams(the_work: list[Any]) -> list[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def _draft_cell_map(draft: dict[str, Any]) -> list[tuple[Team, UUID]]:
|
||||
"""The draft's ad-hoc per-cell project map.
|
||||
|
||||
One ``(team, project_id)`` per ``the_work`` entry whose ``team`` is a valid
|
||||
cell AND that carries a ``project_id``, in ``the_work`` order, de-duped by
|
||||
team (the first mapping for a cell wins — a ``task_cell_projects`` row is
|
||||
unique per ``(task, team)``). Empty when no entry carries a project_id — the
|
||||
draft then falls back to its top-level ``project_id`` (single-cell legacy).
|
||||
|
||||
This is the multi-cell MegaTask root-subtask seam: a draft whose map has
|
||||
≥2 entries targets the ad-hoc cell-map shape (no project, no product), and
|
||||
``create_task_from_draft`` persists those rows on the root-subtask.
|
||||
"""
|
||||
cell_values = {t.value for t in CELL_TEAMS}
|
||||
out: list[tuple[Team, UUID]] = []
|
||||
seen_teams: set[Team] = set()
|
||||
for entry in draft.get("the_work") or []:
|
||||
e = _as_work_entry(entry)
|
||||
team_raw = str(e.get("team", ""))
|
||||
if team_raw not in cell_values:
|
||||
continue
|
||||
team = Team(team_raw)
|
||||
if team in seen_teams:
|
||||
continue
|
||||
pid_raw = e.get("project_id")
|
||||
if not pid_raw:
|
||||
continue
|
||||
try:
|
||||
pid = UUID(str(pid_raw))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
seen_teams.add(team)
|
||||
out.append((team, pid))
|
||||
return out
|
||||
|
||||
|
||||
def derive_scale(the_work: list[Any]) -> str:
|
||||
"""'multi' when more than one cell participates, else 'single'."""
|
||||
return "multi" if len(_cell_teams(the_work)) > 1 else "single"
|
||||
|
||||
+81
-33
@@ -20,6 +20,7 @@ from roboco.db.tables import (
|
||||
JournalTable,
|
||||
ProjectTable,
|
||||
SessionTaskTable,
|
||||
TaskCellProjectTable,
|
||||
TaskTable,
|
||||
WorkSessionTable,
|
||||
)
|
||||
@@ -564,6 +565,7 @@ class TaskService(BaseService):
|
||||
product_id=task.product_id,
|
||||
batch_id=task.batch_id,
|
||||
parent_task_id=task.parent_task_id,
|
||||
has_cell_projects=bool(task.cell_projects),
|
||||
),
|
||||
# An external-PR review task reviews someone else's PR read-only —
|
||||
# no branch of its own — so it is branch-gate exempt.
|
||||
@@ -743,18 +745,22 @@ class TaskService(BaseService):
|
||||
def _require_target_or_umbrella(req: TaskCreateRequest) -> None:
|
||||
"""Service-layer invariant (covers every create path — API, a2a, gateway).
|
||||
|
||||
A task targets a single repo (``project_id``) or fans out across cells via
|
||||
a product (``product_id``) — it must have one or the other, EXCEPT a
|
||||
MegaTask umbrella, which targets neither (it groups N root-subtasks that
|
||||
each carry their own project) and is branchless.
|
||||
A task targets a single repo (``project_id``), fans out across cells via a
|
||||
product (``product_id``), or carries an ad-hoc per-cell map
|
||||
(``cell_projects``) — it must have exactly one, EXCEPT a MegaTask umbrella,
|
||||
which targets neither (it groups N root-subtasks that each carry their own
|
||||
project) and is branchless.
|
||||
"""
|
||||
if req.project_id is not None or req.product_id is not None:
|
||||
return
|
||||
if req.cell_projects:
|
||||
return
|
||||
if is_batch_umbrella(batch_id=req.batch_id, parent_task_id=req.parent_task_id):
|
||||
return
|
||||
raise ValueError(
|
||||
"task needs a project_id (the repo it targets) or a product_id "
|
||||
"(a cell->project map for a fan-out task)"
|
||||
"task needs a project_id (the repo it targets), a product_id "
|
||||
"(a cell->project map for a fan-out task), or cell_projects "
|
||||
"(an ad-hoc per-cell map for a multi-cell coordination root)"
|
||||
)
|
||||
|
||||
async def _validate_batch_membership(self, req: TaskCreateRequest) -> None:
|
||||
@@ -773,11 +779,12 @@ class TaskService(BaseService):
|
||||
parent_task_id=req.parent_task_id,
|
||||
project_id=req.project_id,
|
||||
product_id=req.product_id,
|
||||
has_cell_projects=bool(req.cell_projects),
|
||||
):
|
||||
raise ValueError(
|
||||
"batch_id is only valid on a MegaTask umbrella (targets neither "
|
||||
"project nor product) or a root-subtask (targets exactly one); "
|
||||
"refusing a stray batch_id on any other task."
|
||||
"project, product, nor a cell map) or a root-subtask (targets "
|
||||
"exactly one); refusing a stray batch_id on any other task."
|
||||
)
|
||||
if req.parent_task_id is None:
|
||||
return # a well-formed umbrella
|
||||
@@ -843,6 +850,22 @@ class TaskService(BaseService):
|
||||
self.session.add(task)
|
||||
await self.session.flush()
|
||||
|
||||
# Persist the ad-hoc per-cell project map (a MegaTask root-subtask
|
||||
# spanning multiple cells). Added as explicit rows with the flushed
|
||||
# task id rather than via the `cell_projects` collection (which is
|
||||
# unloaded on a freshly-built task — mutating it would trigger a lazy
|
||||
# load). Unique (task_id, team) is enforced by the table; a caller
|
||||
# passing duplicate teams raises IntegrityError here, which is the
|
||||
# right failure for a malformed request.
|
||||
for mapping in req.cell_projects:
|
||||
self.session.add(
|
||||
TaskCellProjectTable(
|
||||
task_id=cast("UUID", task.id),
|
||||
team=mapping.team,
|
||||
project_id=mapping.project_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Inherit parent task's primary session for subtasks
|
||||
if req.parent_task_id:
|
||||
await self._inherit_parent_session(
|
||||
@@ -1558,12 +1581,13 @@ class TaskService(BaseService):
|
||||
|
||||
if not task.project_id:
|
||||
# A coordination/fan-out task carries a product (a cell->project
|
||||
# map) but no repo of its own. Per the CEO-locked branch model it is
|
||||
# the Main-PM integration point: it cuts feature/main_pm/{root} off
|
||||
# master in EACH repo the product spans, so cells branch off it
|
||||
# (not off master) and only the CEO merges the root into master.
|
||||
# Only a task with neither project nor product is misconfigured.
|
||||
if task.product_id:
|
||||
# map) or an ad-hoc cell_projects map but no repo of its own. Per
|
||||
# the CEO-locked branch model it is the Main-PM integration point:
|
||||
# it cuts feature/main_pm/{root} off master in EACH repo the map
|
||||
# spans, so cells branch off it (not off master) and only the CEO
|
||||
# merges the root into master. Only a task with neither project,
|
||||
# product, nor a cell map is misconfigured.
|
||||
if task.product_id or task.cell_projects:
|
||||
return await self._ensure_coordination_root_branches(task, agent_id)
|
||||
# A MegaTask umbrella is branchless by design: it spans many projects
|
||||
# (no single master to branch off) and assembles no PR of its own —
|
||||
@@ -1574,9 +1598,9 @@ class TaskService(BaseService):
|
||||
):
|
||||
return ""
|
||||
raise ValueError(
|
||||
"Task requires a project_id (a repo) or a product_id (a "
|
||||
"cell->project map) to create a branch. Assign one before "
|
||||
"claiming."
|
||||
"Task requires a project_id (a repo), a product_id, or a "
|
||||
"cell_projects map (a cell->project map) to create a branch. "
|
||||
"Assign one before claiming."
|
||||
)
|
||||
|
||||
return await self._auto_create_branch(task, agent_id)
|
||||
@@ -1762,33 +1786,55 @@ class TaskService(BaseService):
|
||||
)
|
||||
return branch_name
|
||||
|
||||
async def _distinct_projects_for_task(self, task: TaskTable) -> list[UUID]:
|
||||
"""The distinct projects a coordination root's map spans — one
|
||||
``feature/main_pm/{root}`` integration branch each.
|
||||
|
||||
A coordination root carries EITHER a product (``product_id`` → its
|
||||
``product_projects`` map) OR an ad-hoc per-cell map (``cell_projects``).
|
||||
Both yield the same thing: the distinct project_ids the root spans,
|
||||
de-duped (a monorepo mapped across cells yields one project per distinct
|
||||
project, in team order). Empty when the root has neither (the umbrella,
|
||||
which never reaches here, or a not-yet-mapped product — caller returns
|
||||
``""`` so delegation falls back to the parent's project).
|
||||
"""
|
||||
from roboco.services.product import get_product_service
|
||||
|
||||
if task.product_id is not None:
|
||||
return await get_product_service(self.session).distinct_project_ids(
|
||||
UUID(str(task.product_id))
|
||||
)
|
||||
# Ad-hoc cell_projects map: de-dupe by project_id, in team order (mirrors
|
||||
# ProductService.distinct_project_ids' ordering + dedup semantics).
|
||||
seen: dict[UUID, None] = {}
|
||||
for mapping in sorted(task.cell_projects, key=lambda m: m.team.value):
|
||||
seen.setdefault(UUID(str(mapping.project_id)), None)
|
||||
return list(seen)
|
||||
|
||||
async def _ensure_coordination_root_branches(
|
||||
self,
|
||||
task: TaskTable,
|
||||
agent_id: UUID,
|
||||
) -> str:
|
||||
"""Cut the Main-PM integration branch in every repo the product spans.
|
||||
"""Cut the Main-PM integration branch in every repo the map spans.
|
||||
|
||||
The coordination root carries a product (a cell->repo map) but no
|
||||
project of its own. Per the CEO-locked model, the Main-PM root branches
|
||||
``feature/main_pm/{root}`` OFF master in each distinct repo; cells then
|
||||
branch off it (via the parent-branch resolution) instead of off master,
|
||||
so cell work never targets master — only the CEO merges the root branch
|
||||
into master, per repo. Monorepo => one branch; multi-repo => N.
|
||||
The coordination root carries a product (a cell->repo map) or an ad-hoc
|
||||
``cell_projects`` map, but no project of its own. Per the CEO-locked model,
|
||||
the Main-PM root branches ``feature/main_pm/{root}`` OFF master in each
|
||||
distinct repo the map spans; cells then branch off it (via the
|
||||
parent-branch resolution) instead of off master, so cell work never
|
||||
targets master — only the CEO merges the root branch into master, per
|
||||
repo. Monorepo => one branch; multi-repo => N.
|
||||
|
||||
Returns the shared branch name (identical across repos), or ``""`` when
|
||||
the product has no cell->repo map yet (delegation then falls back to the
|
||||
parent's project per the routing spec, and the root stays branchless).
|
||||
the map has no projects yet (delegation then falls back to the parent's
|
||||
project per the routing spec, and the root stays branchless).
|
||||
"""
|
||||
from roboco.services.product import get_product_service
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
product_service = get_product_service(self.session)
|
||||
project_service = get_project_service(self.session)
|
||||
|
||||
project_ids = await product_service.distinct_project_ids(
|
||||
UUID(str(task.product_id))
|
||||
)
|
||||
project_ids = await self._distinct_projects_for_task(task)
|
||||
branch_name = ""
|
||||
for project_id in project_ids:
|
||||
project = await project_service.get(project_id)
|
||||
@@ -1840,11 +1886,12 @@ class TaskService(BaseService):
|
||||
parent_task_id=getattr(task, "parent_task_id", None),
|
||||
project_id=getattr(task, "project_id", None),
|
||||
product_id=getattr(task, "product_id", None),
|
||||
has_cell_projects=bool(getattr(task, "cell_projects", None)),
|
||||
):
|
||||
raise ValueError(
|
||||
"this update would break the task's MegaTask shape: a batch "
|
||||
"member must stay an umbrella (targets neither project nor "
|
||||
"product) or a root-subtask (exactly one target, parented)."
|
||||
"member must stay an umbrella (targets neither project, product, "
|
||||
"nor a cell map) or a root-subtask (exactly one target, parented)."
|
||||
)
|
||||
|
||||
async def update(
|
||||
@@ -5043,6 +5090,7 @@ class TaskService(BaseService):
|
||||
product_id=task.product_id,
|
||||
batch_id=task.batch_id,
|
||||
parent_task_id=task.parent_task_id,
|
||||
has_cell_projects=bool(task.cell_projects),
|
||||
)
|
||||
if not is_coordination_root:
|
||||
self._validate_and_set_status(task, TaskStatus.NEEDS_REVISION, "ceo")
|
||||
|
||||
@@ -30,6 +30,7 @@ from roboco.api.schemas.tasks import (
|
||||
transform_update_data,
|
||||
)
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||
from roboco.models.product import ProductCellMapping
|
||||
|
||||
_ORDER_DEFAULT = 0
|
||||
|
||||
@@ -291,6 +292,7 @@ def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
|
||||
task_type=TaskType.CODE,
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
cell_projects=[],
|
||||
project=(SimpleNamespace(slug="proj-1") if with_project else None),
|
||||
docs_complete=False,
|
||||
pr_created=False,
|
||||
@@ -348,6 +350,38 @@ def test_task_to_response_includes_slug_when_project_loaded() -> None:
|
||||
assert resp.project_slug == "proj-1"
|
||||
|
||||
|
||||
def test_task_to_response_serializes_cell_projects_when_loaded() -> None:
|
||||
"""An ad-hoc per-cell map (a multi-cell MegaTask root-subtask) round-trips
|
||||
into the response when the relationship is loaded."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
stub = _stub_task()
|
||||
stub.project_id = None
|
||||
stub.product_id = None
|
||||
stub.cell_projects = [
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=be_proj),
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = set() # cell_projects IS loaded
|
||||
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
||||
resp = task_to_response(stub) # type: ignore[arg-type]
|
||||
assert resp.cell_projects == [
|
||||
ProductCellMapping(team=Team.BACKEND, project_id=be_proj),
|
||||
ProductCellMapping(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
|
||||
|
||||
def test_task_to_response_omits_cell_projects_when_unloaded() -> None:
|
||||
"""A freshly-created task whose cell_projects relationship is unloaded
|
||||
serializes to [] rather than triggering a lazy load."""
|
||||
stub = _stub_task()
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = {"cell_projects"}
|
||||
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
|
||||
resp = task_to_response(stub) # type: ignore[arg-type]
|
||||
assert resp.cell_projects == []
|
||||
|
||||
|
||||
def test_task_to_response_serializes_all_note_sections() -> None:
|
||||
"""Regression: pr_reviewer_notes / doc_notes / notes_structured MUST be in the
|
||||
response. The builder previously omitted them, so the panel showed them blank
|
||||
|
||||
@@ -46,10 +46,33 @@ def test_branchless_coordination_excludes_normal_and_root_subtasks() -> None:
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
)
|
||||
# genuinely unroutable (none of project / product / batch) stays gated
|
||||
# genuinely unroutable (none of project / product / batch / cell-map) stays gated
|
||||
assert not is_branchless_coordination(project_id=None, product_id=None)
|
||||
|
||||
|
||||
def test_branchless_coordination_covers_ad_hoc_cell_map_root() -> None:
|
||||
# An ad-hoc per-cell project map (no project_id, no product_id, carries a
|
||||
# cell map) is a coordination root exactly like a Product fan-out root: it
|
||||
# cuts feature/main_pm/{root} per repo and opens a root->master PR per repo,
|
||||
# so the claim branch gate skips the single-branch requirement. Holds both
|
||||
# for a MegaTask root-subtask and a standalone coordination root.
|
||||
assert is_branchless_coordination(
|
||||
project_id=None, product_id=None, has_cell_projects=True
|
||||
)
|
||||
assert is_branchless_coordination(
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# a cell map is NOT branchless if a project_id is also set (then it's a normal
|
||||
# project task that happens to carry a stray map — gated, not exempt).
|
||||
assert not is_branchless_coordination(
|
||||
project_id=uuid4(), product_id=None, has_cell_projects=True
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_allows_umbrella_and_root_subtask() -> None:
|
||||
bid = uuid4()
|
||||
# umbrella: batch_id, no parent, NO target
|
||||
@@ -89,3 +112,54 @@ def test_valid_batch_shape_denies_stray_batch_id() -> None:
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=uuid4(), product_id=uuid4()
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_allows_ad_hoc_cell_map_root_subtask() -> None:
|
||||
bid = uuid4()
|
||||
# a root-subtask carrying an ad-hoc per-cell map (no project, no product) is a
|
||||
# well-formed third targeting shape — exactly one of {project, product, map}.
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# a non-batch task carrying a cell map is unconstrained here (the normal
|
||||
# targeting rule applies; the map is a coordination-root shape in its own
|
||||
# right, not a batch-only construct).
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_denies_cell_map_alongside_another_target() -> None:
|
||||
bid = uuid4()
|
||||
# umbrella with a cell map: an umbrella must target NEITHER — a map is a target.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# root-subtask with BOTH a project and a cell map: two targets, malformed.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
has_cell_projects=True,
|
||||
)
|
||||
# root-subtask with BOTH a product and a cell map: two targets, malformed.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid,
|
||||
parent_task_id=uuid4(),
|
||||
project_id=None,
|
||||
product_id=uuid4(),
|
||||
has_cell_projects=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Choreographer._resolve_subtask_project — fan-out resolution coverage.
|
||||
|
||||
The method resolves which project a delegated subtask lands in. It has four
|
||||
priority tiers, exercised here against a stub ``self`` (the cell-map match
|
||||
path never touches ``self``, and the raise path only reads ``self.product``):
|
||||
|
||||
1. explicit ``inputs.project_id`` wins outright.
|
||||
2. the parent's ad-hoc ``cell_projects`` map → the mapping for ``inputs.team``.
|
||||
3. the parent's Product map (delegated to ``self.product.project_for``).
|
||||
4. the parent's own ``project_id``.
|
||||
5. otherwise ``TaskCompletenessError`` (no repo to land in).
|
||||
|
||||
The ad-hoc cell-map tier (2) is the multi-cell MegaTask root-subtask seam; the
|
||||
other tiers are unchanged product-root / single-project behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy.task_completeness import TaskCompletenessError
|
||||
from roboco.models.base import Team
|
||||
from roboco.services.gateway.choreographer._impl import (
|
||||
Choreographer,
|
||||
DelegateInputs,
|
||||
)
|
||||
|
||||
|
||||
def _inputs(*, team: Team, project_id: UUID | None = None) -> DelegateInputs:
|
||||
return DelegateInputs(
|
||||
title="t",
|
||||
description="d",
|
||||
assigned_to="be-dev-1",
|
||||
team=team.value,
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["a"],
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
def _mapping(team: Team, project_id: UUID) -> SimpleNamespace:
|
||||
return SimpleNamespace(team=team, project_id=project_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_inputs_project_id_wins_over_cell_map() -> None:
|
||||
"""Tier 1: an explicit project_id on delegate short-circuits the map."""
|
||||
be_proj, explicit = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.BACKEND, project_id=explicit)
|
||||
)
|
||||
assert resolved == explicit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_resolves_project_for_matching_team() -> None:
|
||||
"""Tier 2: the parent's cell map yields the project for the delegated cell."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[
|
||||
_mapping(Team.FRONTEND, fe_proj),
|
||||
_mapping(Team.BACKEND, be_proj),
|
||||
],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.BACKEND)
|
||||
)
|
||||
assert resolved == be_proj
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_missing_team_falls_through_to_parent_project() -> None:
|
||||
"""No mapping for the requested cell → fall through to the parent's own
|
||||
project_id (tier 4), not raise — the parent may still carry a project."""
|
||||
be_proj, own = uuid4(), uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=own,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
# Frontend subtask but the map only covers backend → fall to parent.project_id.
|
||||
resolved = await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.FRONTEND)
|
||||
)
|
||||
assert resolved == own
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_map_only_parent_with_no_match_raises_completeness() -> None:
|
||||
"""A fan-out parent (cell map, no own project, no product) with no mapping
|
||||
for the requested cell raises TaskCompletenessError — the subtask has no
|
||||
repo to land in."""
|
||||
be_proj = uuid4()
|
||||
parent = SimpleNamespace(
|
||||
cell_projects=[_mapping(Team.BACKEND, be_proj)],
|
||||
product_id=None,
|
||||
project_id=None,
|
||||
)
|
||||
self_stub = SimpleNamespace(product=None)
|
||||
with pytest.raises(TaskCompletenessError) as exc:
|
||||
await Choreographer._resolve_subtask_project(
|
||||
self_stub, parent, _inputs(team=Team.FRONTEND)
|
||||
)
|
||||
assert "project_id" in exc.value.missing
|
||||
@@ -32,6 +32,7 @@ from roboco.services.base import ServiceError, ValidationError
|
||||
from roboco.services.prompter import (
|
||||
PrompterService,
|
||||
_cell_teams,
|
||||
_draft_cell_map,
|
||||
compose_description,
|
||||
derive_scale,
|
||||
get_prompter_service,
|
||||
@@ -102,6 +103,7 @@ def test_derive_scale_single_vs_multi() -> None:
|
||||
# "'str' object has no attribute 'get'").
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cell_teams_tolerates_bare_string_entries() -> None:
|
||||
# The LLM emitted the_work as a list of team names, not objects.
|
||||
assert _cell_teams(["backend", "frontend", "backend"]) == ["backend", "frontend"]
|
||||
@@ -697,3 +699,193 @@ def test_preview_batch_tolerates_bare_string_the_work() -> None:
|
||||
result = service.preview_batch(drafts)
|
||||
assert isinstance(result["waves"], list)
|
||||
assert isinstance(result["warnings"], list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Per-cell project map (multi-cell MegaTask root-subtask seam) — pure helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _work(team: str, project_id: UUID | None) -> dict[str, Any]:
|
||||
entry: dict[str, Any] = {"team": team, "summary": "s", "items": ["x"]}
|
||||
if project_id is not None:
|
||||
entry["project_id"] = str(project_id)
|
||||
return entry
|
||||
|
||||
|
||||
def test_draft_cell_map_collects_per_cell_projects_in_order() -> None:
|
||||
"""A multi-cell draft yields one (team, project_id) per the_work entry,
|
||||
in the_work order, de-duped by team."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj), (Team.FRONTEND, fe_proj)]
|
||||
|
||||
|
||||
def test_draft_cell_map_dedupes_repeated_team_keeping_first() -> None:
|
||||
"""Two entries for the same cell (LLM noise) keep the first mapping — a
|
||||
task_cell_projects row is unique per (task, team)."""
|
||||
first, second = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", first),
|
||||
_work("backend", second),
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, first)]
|
||||
|
||||
|
||||
def test_draft_cell_map_skips_entries_without_project_id() -> None:
|
||||
"""An entry with no project_id (single-cell legacy or a bare team string) is
|
||||
skipped — the draft then falls back to its top-level project_id."""
|
||||
be_proj = uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
{"team": "frontend", "summary": "s", "items": []}, # no project_id
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, be_proj)]
|
||||
|
||||
|
||||
def test_draft_cell_map_empty_when_no_entry_has_project_id() -> None:
|
||||
"""A legacy single-cell draft (top-level project_id, bare-string the_work)
|
||||
yields an empty map — the caller falls back to the top-level project_id."""
|
||||
assert _draft_cell_map({"the_work": ["backend", "frontend"]}) == []
|
||||
assert _draft_cell_map({"the_work": [{"team": "backend"}]}) == []
|
||||
|
||||
|
||||
def test_draft_cell_map_ignores_off_enum_teams_and_bad_uuids() -> None:
|
||||
"""Off-enum team names and malformed project_ids are skipped, not crashed on
|
||||
(the intake agent is an LLM)."""
|
||||
good = uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", good),
|
||||
{"team": "marketing", "project_id": str(uuid4())}, # not a cell
|
||||
_work("frontend", None), # missing
|
||||
{"team": "ux_ui", "project_id": "not-a-uuid"}, # bad uuid
|
||||
]
|
||||
}
|
||||
assert _draft_cell_map(draft) == [(Team.BACKEND, good)]
|
||||
|
||||
|
||||
def test_validate_batch_scope_accepts_single_multi_cell_draft() -> None:
|
||||
"""One 2-cell draft already spans ≥2 distinct projects → valid MegaTask."""
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
],
|
||||
}
|
||||
]
|
||||
# Must not raise: 2 distinct projects across the one draft's cells.
|
||||
PrompterService._validate_batch_scope(drafts, [be_proj, fe_proj])
|
||||
|
||||
|
||||
def test_validate_batch_scope_rejects_out_of_scope_per_cell_project() -> None:
|
||||
"""A per-cell project_id outside the scoped set is refused."""
|
||||
in_scope, out_of_scope = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [
|
||||
_work("backend", in_scope),
|
||||
_work("frontend", out_of_scope),
|
||||
],
|
||||
}
|
||||
]
|
||||
with pytest.raises(ValidationError, match="outside this MegaTask"):
|
||||
PrompterService._validate_batch_scope(drafts, [in_scope, uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_rejects_draft_with_no_project() -> None:
|
||||
"""A draft with neither a per-cell map nor a top-level project_id is refused."""
|
||||
drafts = [
|
||||
{
|
||||
"title": "S1",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [_work("backend", None), _work("frontend", None)],
|
||||
}
|
||||
]
|
||||
with pytest.raises(ValidationError, match="has no project"):
|
||||
PrompterService._validate_batch_scope(drafts, [uuid4(), uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_distinct_count_spans_all_cells() -> None:
|
||||
"""The ≥2 minimum counts distinct projects across ALL drafts' cells, not per
|
||||
draft. Two single-cell drafts on the same project still fail (degenerate)."""
|
||||
only = uuid4()
|
||||
drafts = [
|
||||
{
|
||||
"title": "A",
|
||||
"acceptance_criteria": ["a"],
|
||||
"the_work": [_work("backend", only)],
|
||||
},
|
||||
{
|
||||
"title": "B",
|
||||
"acceptance_criteria": ["b"],
|
||||
"the_work": [_work("frontend", only)], # same project, different cell
|
||||
},
|
||||
]
|
||||
with pytest.raises(ValidationError, match="at least two distinct projects"):
|
||||
PrompterService._validate_batch_scope(drafts, [only, uuid4()])
|
||||
|
||||
|
||||
def test_validate_batch_scope_legacy_single_cell_drafts_still_work() -> None:
|
||||
"""Back-compat: drafts using a top-level project_id (no the_work map) still
|
||||
validate against the scope and the ≥2 distinct minimum."""
|
||||
p1, p2 = uuid4(), uuid4()
|
||||
drafts = [
|
||||
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(p1)},
|
||||
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(p2)},
|
||||
]
|
||||
PrompterService._validate_batch_scope(drafts, [p1, p2])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_owning_team_multi_cell_map_routes_to_main_pm() -> None:
|
||||
"""A multi-cell ad-hoc map is a coordination root (mirrors a product root), so
|
||||
it routes to the Main PM — never the lead cell (a cell PM can't delegate
|
||||
cross-cell; that would deadlock the fan-out). No DB access on this branch."""
|
||||
service = get_prompter_service() # no db — the cell-map branch never reads it
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
draft = {
|
||||
"the_work": [
|
||||
_work("backend", be_proj),
|
||||
_work("frontend", fe_proj),
|
||||
]
|
||||
}
|
||||
team = await service._resolve_owning_team(
|
||||
draft,
|
||||
resolved_product_id=None,
|
||||
resolved_assigned_to=None,
|
||||
team_override=None,
|
||||
default_lead=Team.BACKEND,
|
||||
)
|
||||
assert team is Team.MAIN_PM
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_owning_team_single_cell_still_routes_to_lead_cell() -> None:
|
||||
"""A single-cell project draft (no product, no multi-cell map) keeps its
|
||||
legacy owner: the lead cell."""
|
||||
service = get_prompter_service()
|
||||
draft = {"the_work": [_work("backend", uuid4())]}
|
||||
team = await service._resolve_owning_team(
|
||||
draft,
|
||||
resolved_product_id=None,
|
||||
resolved_assigned_to=None,
|
||||
team_override=None,
|
||||
default_lead=Team.BACKEND,
|
||||
)
|
||||
assert team is Team.BACKEND
|
||||
|
||||
@@ -993,10 +993,66 @@ async def test_ensure_branch_coordination_root_no_cell_map_stays_branchless() ->
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
|
||||
"""A task with neither a project nor a product is genuinely misconfigured."""
|
||||
async def test_ensure_branch_cell_map_root_cuts_integration_branch_per_project() -> (
|
||||
None
|
||||
):
|
||||
"""An ad-hoc cell_projects root cuts feature/main_pm/{root} in each distinct
|
||||
project the map spans — the product-root path with the map sourced from the
|
||||
task instead of a Product."""
|
||||
svc = TaskService(MagicMock())
|
||||
task = MagicMock(branch_name=None, project_id=None, product_id=None)
|
||||
be_proj, fe_proj = uuid4(), uuid4()
|
||||
cell_map = [
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=be_proj),
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=fe_proj),
|
||||
]
|
||||
task = MagicMock(
|
||||
branch_name=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
cell_projects=cell_map,
|
||||
)
|
||||
create_in_project = AsyncMock(return_value="feature/main_pm/root1234")
|
||||
_bind(svc, "_create_branch_in_project", create_in_project)
|
||||
project_svc = MagicMock(get=AsyncMock(return_value=MagicMock()))
|
||||
with patch("roboco.services.project.get_project_service", return_value=project_svc):
|
||||
result = await svc._ensure_branch_for_task(task, uuid4())
|
||||
assert result == "feature/main_pm/root1234"
|
||||
# one integration branch per distinct project in the map (here 2 cells, 2 projects)
|
||||
assert create_in_project.await_count == len(cell_map)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distinct_projects_for_task_dedupes_cell_map_by_project_id() -> None:
|
||||
"""Two cells mapping at the same project (the monorepo case) yield ONE
|
||||
integration branch, not two — mirroring product distinct_project_ids."""
|
||||
svc = TaskService(MagicMock())
|
||||
shared = uuid4()
|
||||
task = MagicMock(
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
cell_projects=[
|
||||
SimpleNamespace(team=Team.FRONTEND, project_id=shared),
|
||||
SimpleNamespace(team=Team.BACKEND, project_id=shared),
|
||||
],
|
||||
)
|
||||
ids = await svc._distinct_projects_for_task(task)
|
||||
assert ids == [shared]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
|
||||
"""A task with neither a project, a product, nor a cell map is misconfigured."""
|
||||
svc = TaskService(MagicMock())
|
||||
task = MagicMock(
|
||||
branch_name=None,
|
||||
project_id=None,
|
||||
product_id=None,
|
||||
cell_projects=[],
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
await svc._ensure_branch_for_task(task, uuid4())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user