2026-07-10 00:15:28 +02:00
|
|
|
"""Reproduce the panel's confirm-batch payload shape.
|
|
|
|
|
|
|
|
|
|
The panel's ``rebuildTheWork`` writes each task's project into
|
|
|
|
|
``the_work[].project_id`` and sets the top-level ``project_id`` to ``null``
|
|
|
|
|
(multi-cell model), whereas the existing MegaTask tests use the legacy
|
|
|
|
|
top-level ``project_id``. This drives ``confirm_live_batch`` with the real
|
|
|
|
|
panel shape to surface the live 500.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, Literal, cast
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.services.prompter import get_prompter_service
|
|
|
|
|
from tests.unit.services.test_prompter import (
|
|
|
|
|
_FakeRedis,
|
|
|
|
|
_seed_project_and_ceo,
|
|
|
|
|
_seed_second_project,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_confirm_live_batch_panel_the_work_shape(db_session: Any) -> None:
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
# Panel shape: project lives in the_work[].project_id, top-level is null.
|
|
|
|
|
drafts: list[dict[str, Any]] = [
|
|
|
|
|
{
|
|
|
|
|
"title": "A: backend work",
|
|
|
|
|
"acceptance_criteria": ["a"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "backend",
|
|
|
|
|
"summary": "do the thing",
|
|
|
|
|
"items": ["unit 1"],
|
|
|
|
|
"project_id": str(project1),
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"intends_to_touch": ["roboco/services/foo.py"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"title": "B: frontend work",
|
|
|
|
|
"acceptance_criteria": ["b"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "frontend",
|
|
|
|
|
"summary": "ui",
|
|
|
|
|
"items": ["unit 1"],
|
|
|
|
|
"project_id": str(project2),
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"intends_to_touch": ["panel/src/widget.tsx"],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()):
|
|
|
|
|
result = await service.confirm_live_batch(
|
|
|
|
|
"Panel shape batch",
|
|
|
|
|
drafts,
|
|
|
|
|
ceo_id,
|
|
|
|
|
project_ids=[project1, project2],
|
|
|
|
|
route="main_pm",
|
|
|
|
|
session_id=f"sess-panel-{uuid4().hex[:8]}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ids = result["root_subtask_ids"]
|
|
|
|
|
assert len(ids) == len(drafts)
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 03:59:13 +02:00
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 00:15:28 +02:00
|
|
|
@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) →
|
|
|
|
|
the cell_projects persistence path on a batch root-subtask."""
|
|
|
|
|
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: cross-cell task",
|
|
|
|
|
"acceptance_criteria": ["a"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "backend",
|
|
|
|
|
"summary": "api",
|
|
|
|
|
"items": ["u1"],
|
|
|
|
|
"project_id": str(project1),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"team": "frontend",
|
|
|
|
|
"summary": "ui",
|
|
|
|
|
"items": ["u2"],
|
|
|
|
|
"project_id": str(project2),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"title": "B: backend only",
|
|
|
|
|
"acceptance_criteria": ["b"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "backend",
|
|
|
|
|
"summary": "more api",
|
|
|
|
|
"items": ["u1"],
|
|
|
|
|
"project_id": str(project1),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()):
|
|
|
|
|
result = await service.confirm_live_batch(
|
|
|
|
|
"Cross-cell batch",
|
|
|
|
|
drafts,
|
|
|
|
|
ceo_id,
|
|
|
|
|
project_ids=[project1, project2],
|
|
|
|
|
route="main_pm",
|
|
|
|
|
session_id=f"sess-mc-{uuid4().hex[:8]}",
|
|
|
|
|
)
|
|
|
|
|
assert len(result["root_subtask_ids"]) == len(drafts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize("route", ["main_pm", "board"])
|
|
|
|
|
async def test_confirm_live_batch_dense_same_repo_collisions(
|
|
|
|
|
db_session: Any, route: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The real panel-MegaTask shape: MANY tasks in the SAME repo with heavy
|
|
|
|
|
file overlap → dense sequencing edges. Repro for the live confirm-batch 500."""
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
shared = "panel/src/app/layout.tsx"
|
|
|
|
|
drafts: list[dict[str, Any]] = []
|
|
|
|
|
# 6 backend/panel tasks in project1, overlapping on a shared file + migrations.
|
|
|
|
|
for i in range(6):
|
|
|
|
|
drafts.append(
|
|
|
|
|
{
|
|
|
|
|
"title": f"Panel task {i}",
|
|
|
|
|
"acceptance_criteria": [f"c{i}"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "frontend",
|
|
|
|
|
"summary": f"s{i}",
|
|
|
|
|
"items": ["u"],
|
|
|
|
|
"project_id": str(project1),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"intends_to_touch": [shared, f"panel/src/f{i}.tsx"],
|
|
|
|
|
"adds_migration": i % 2 == 0,
|
|
|
|
|
"touches_shared": True,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
# 1 task in project2 so the batch spans ≥2 repos.
|
|
|
|
|
drafts.append(
|
|
|
|
|
{
|
|
|
|
|
"title": "Other repo",
|
|
|
|
|
"acceptance_criteria": ["c"],
|
|
|
|
|
"project_id": None,
|
|
|
|
|
"the_work": [
|
|
|
|
|
{
|
|
|
|
|
"team": "frontend",
|
|
|
|
|
"summary": "s",
|
|
|
|
|
"items": ["u"],
|
|
|
|
|
"project_id": str(project2),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"intends_to_touch": ["src/other.ts"],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()):
|
|
|
|
|
result = await service.confirm_live_batch(
|
|
|
|
|
"Panel MegaTask",
|
|
|
|
|
drafts,
|
|
|
|
|
ceo_id,
|
|
|
|
|
project_ids=[project1, project2],
|
|
|
|
|
route=cast("Literal['board', 'main_pm']", route),
|
|
|
|
|
session_id=f"sess-dense-{route}-{uuid4().hex[:8]}",
|
|
|
|
|
)
|
|
|
|
|
assert len(result["root_subtask_ids"]) == len(drafts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _DeleteFakeRedis(_FakeRedis):
|
|
|
|
|
"""_FakeRedis + delete tracking, for the guard-release-on-failure test."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.deleted: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def delete(self, name: str) -> int:
|
|
|
|
|
self.deleted.append(name)
|
|
|
|
|
return int(self._store.pop(name, None) is not None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_confirm_live_batch_releases_guard_on_build_failure(
|
|
|
|
|
db_session: Any,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A build failure must DELETE the idempotency guard so a retry re-attempts,
|
|
|
|
|
instead of the 1h key wedging every retry into a 500 ('already in progress').
|
|
|
|
|
"""
|
|
|
|
|
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",
|
|
|
|
|
"acceptance_criteria": ["a"],
|
|
|
|
|
"team": "backend",
|
|
|
|
|
"project_id": str(project1),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"title": "B",
|
|
|
|
|
"acceptance_criteria": ["b"],
|
|
|
|
|
"team": "frontend",
|
|
|
|
|
"project_id": str(project2),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
fake = _DeleteFakeRedis()
|
|
|
|
|
session_id = f"sess-fail-{uuid4().hex[:8]}"
|
|
|
|
|
|
|
|
|
|
async def _boom(*_a: Any, **_kw: Any) -> Any:
|
|
|
|
|
raise RuntimeError("build blew up")
|
|
|
|
|
|
|
|
|
|
with patch("roboco.services.prompter.redis.from_url", return_value=fake):
|
|
|
|
|
with (
|
|
|
|
|
patch.object(service, "_build_confirm_batch", _boom),
|
|
|
|
|
pytest.raises(RuntimeError, match="build blew up"),
|
|
|
|
|
):
|
|
|
|
|
await service.confirm_live_batch(
|
|
|
|
|
"Batch",
|
|
|
|
|
drafts,
|
|
|
|
|
ceo_id,
|
|
|
|
|
project_ids=[project1, project2],
|
|
|
|
|
route="main_pm",
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
)
|
|
|
|
|
# The guard was released, so the key no longer holds the session.
|
|
|
|
|
assert f"roboco:megatask_confirm:{session_id}" in fake.deleted
|
|
|
|
|
# A retry can now re-acquire the guard (not a permanent 'in progress').
|
|
|
|
|
assert await service._acquire_confirm_guard(session_id) is None
|