diff --git a/roboco/services/prompter.py b/roboco/services/prompter.py index e3c91db3..d7fa43e4 100644 --- a/roboco/services/prompter.py +++ b/roboco/services/prompter.py @@ -827,12 +827,7 @@ class PrompterService: # sequence = its wave index and the umbrella as parent. task_of: dict[int, UUID] = {} for idx, draft in enumerate(drafts): - # Root-subtasks are coordination roots: assignment is the - # PM-activation flow's call, not a draft-carried field. Strip a - # hallucinated/injected assigned_to so a board-role uuid can't - # deadlock the umbrella (board roles have no dev delivery verbs). - sub_draft = dict(draft) - sub_draft.pop("assigned_to", None) + sub_draft = _batch_subtask_draft(draft) sub = await self.create_task_from_draft( sub_draft, agent_id, @@ -1087,6 +1082,30 @@ def _cell_teams(the_work: list[Any]) -> list[str]: return seen +def _batch_subtask_draft(draft: dict[str, Any]) -> dict[str, Any]: + """A batch root-subtask's draft: a copy with the non-targeting fields dropped. + + ``assigned_to`` is stripped — assignment is the PM-activation flow's call, not + a draft-carried field, and a hallucinated/injected board-role uuid would + deadlock the umbrella (board roles have no dev delivery verbs). The top-level + ``project_id``/``product_id`` is stripped when the ``the_work`` per-cell map + carries the real target: the intake agent authors the top-level as the repo + SLUG it read (e.g. ``"roboco-api"``), which the panel only clears on a manual + picker toggle, so an untouched draft would reach ``create_task_from_draft``'s + UUID parse and 400 the whole batch. A legacy no-``the_work`` draft keeps its + panel-filled top-level target. + """ + out = dict(draft) + out.pop("assigned_to", None) + if any( + isinstance(cell, dict) and cell.get("project_id") + for cell in (out.get("the_work") or []) + ): + out.pop("project_id", None) + out.pop("product_id", None) + return out + + def _draft_cell_map(draft: dict[str, Any]) -> list[tuple[Team, UUID]]: """The draft's ad-hoc per-cell project map. diff --git a/tests/unit/services/test_prompter_batch_panel_shape.py b/tests/unit/services/test_prompter_batch_panel_shape.py index 1d27d241..0a287862 100644 --- a/tests/unit/services/test_prompter_batch_panel_shape.py +++ b/tests/unit/services/test_prompter_batch_panel_shape.py @@ -73,6 +73,59 @@ async def test_confirm_live_batch_panel_the_work_shape(db_session: Any) -> None: assert len(ids) == len(drafts) +@pytest.mark.asyncio +async def test_confirm_live_batch_ignores_vestigial_top_level_slug( + db_session: Any, +) -> None: + """A batch draft whose the_work cell-map carries the real project UUID but + that also still has the intake agent's top-level project_id SLUG (the panel + only nulls it on a manual picker toggle) must NOT 400 the whole batch on the + UUID parse — the cell map is authoritative. Repro for the live 400 + "Invalid project_id UUID: roboco-api".""" + project1, ceo_id = await _seed_project_and_ceo(db_session) + project2 = await _seed_second_project(db_session, ceo_id) + service = get_prompter_service(db=db_session) + + drafts: list[dict[str, Any]] = [ + { + "title": "A: backend work", + "acceptance_criteria": ["a"], + "project_id": "roboco-api", # agent's slug, panel left it in place + "the_work": [ + { + "team": "backend", + "summary": "s", + "items": ["u"], + "project_id": str(project1), + }, + ], + }, + { + "title": "B: frontend work", + "acceptance_criteria": ["b"], + "project_id": "roboco-panel", # slug on the other draft too + "the_work": [ + { + "team": "frontend", + "summary": "s", + "items": ["u"], + "project_id": str(project2), + }, + ], + }, + ] + with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()): + result = await service.confirm_live_batch( + "Slug batch", + drafts, + ceo_id, + project_ids=[project1, project2], + route="main_pm", + session_id=f"sess-slug-{uuid4().hex[:8]}", + ) + assert len(result["root_subtask_ids"]) == len(drafts) + + @pytest.mark.asyncio async def test_confirm_live_batch_panel_multicell_root_subtask(db_session: Any) -> None: """A batch draft that targets TWO cells (backend+frontend, one repo each) →