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
@@ -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")