Files
roboco/tests/unit/services/test_choreographer_subtask_project.py
T
Renn F 164ce46e66 [fix] MegaTask verification: migration 052 enum + async cell-map read
Two real bugs surfaced running the full gate against a containerized
Postgres (and the orchestrator boot log):

1. Migration 052 crashed a real orchestrator boot with
   'type "team" already exists'. The generic sa.Enum(create_type=False)
   does NOT set the postgres enum's create_type attribute, so op.create_table
   (checkfirst=False) emitted a redundant CREATE TYPE against the pre-existing
   team enum. Switched to postgresql.ENUM(create_type=False) — the postgres-
   native enum whose create_type _check_for_name_in_memos actually reads, so
   the CREATE TYPE is suppressed. Verified: 051->052 upgrade against a DB where
   the team enum pre-existed (the exact path that crashed) now succeeds;
   downgrade 052->051 drops the table and preserves the shared enum; fresh
   upgrade head clean. (Migration 016 has the same latent sa.Enum pattern but
   never re-runs in prod, so it's noted, not touched here.)

2. _ensure_branch_for_task read task.cell_projects (lazy=selectin to-many)
   directly, tripping MissingGreenlet on a freshly-created/unqueried task —
   which then poisoned the async session (PendingRollbackError). Replaced with
   _task_has_cell_map: peeks InstanceState.unloaded (no IO) and reads the
   already-loaded map, falling back to an awaited count query only when the
   relationship is genuinely unloaded. Non-ORM stubs route to the plain
   attribute. Fixes 2 integration tests; the 6 cell-map unit tests still pass.

Also: typed the self stub as Any in test_choreographer_subtask_project
(mypy tests/ wants Choreographer, not SimpleNamespace) — the codebase idiom.

Gate: ruff format/check clean; mypy roboco/ + tests/ clean; full pytest
10371 passed / 388 skipped against containerized pgvector:pg16; vulture clean.
Pre-existing xenon C-rank on reassign (from prior commit 19a474d3, not this
feature) still blocks make quality — surfaced separately.
2026-06-27 00:54:43 +02:00

119 lines
4.1 KiB
Python

"""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 typing import Any
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: Any = 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: Any = 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: Any = 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: Any = 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