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:
Renn F
2026-06-26 23:28:43 +02:00
parent 19a474d389
commit c03e76c433
19 changed files with 1022 additions and 134 deletions
+34
View File
@@ -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
+75 -1
View File
@@ -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
+192
View File
@@ -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
+59 -3
View File
@@ -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())