mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(prompter): board-review → redraft loop for MegaTask batches (#411)
Batch parity with the single-draft keep-alive redraft loop. A first board-route confirm-batch parks the intake session against the umbrella (instead of the unconditional reap), so the existing board-completion injection reaches the still-live chat — now with a batch-aware brief (compose_batch_redraft_message: live root-subtask snapshots + board notes + a one-propose_batch re-proposal instruction). The re-confirm carries BatchConfirmRequest.task_id and routes to the new PrompterService.update_live_batch: in-place umbrella + root-subtask update (positional patch of live children, cancel+recreate on scope change, create/cancel on count change, dependency edges rewired to the fresh wave plan) gated by the same _validate_batch_scope as create. Readers use the CANCELLED-excluding get_live_subtasks view so multi-round redrafts survive earlier cancels. Cold path: re-interview now handles a branchless umbrella by recovering its multi-repo scope from live children (distinct_projects_for_batch) and returning project_ids — fixes the live 400 behind the task-detail redraft button on umbrellas. Panel: confirmBatch board branch keeps the chat open, threads batchRedraftTaskIdRef (persisted) into the re-confirm, treats a redraft re-confirm as terminal on both routes, and surfaces the server's real validation message on confirm failure. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -24,11 +24,18 @@ from roboco.api import deps
|
||||
from roboco.api.deps import get_agent_context
|
||||
from roboco.api.routes.prompter_live import router
|
||||
from roboco.db.base import get_db
|
||||
from roboco.models.base import AgentRole
|
||||
from roboco.db.tables import ProjectTable, TaskTable
|
||||
from roboco.models.base import AgentRole, TaskStatus, Team
|
||||
from roboco.services import prompter_live
|
||||
from roboco.services.base import ValidationError
|
||||
from roboco.services.permissions import AgentContext
|
||||
|
||||
from tests.unit.services.test_prompter import (
|
||||
_confirm_board_batch,
|
||||
_seed_project_and_ceo,
|
||||
_seed_second_project,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
@@ -297,13 +304,20 @@ async def confirm_client(
|
||||
|
||||
ceo = AgentContext(agent_id=uuid4(), role=AgentRole.CEO, team=None, slug="ceo")
|
||||
|
||||
# A real (un-mocked) registry so a board-route confirm can actually park —
|
||||
# a test that wants park-success must first ``registry.open(session_id, …)``.
|
||||
registry = prompter_live.PrompterLiveRegistry()
|
||||
prompter_live._RegistryHolder.instance = registry
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/prompter")
|
||||
app.dependency_overrides[get_db] = _fake_db
|
||||
app.dependency_overrides[get_agent_context] = lambda: ceo
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield {"client": client, "orch": orch}
|
||||
yield {"client": client, "orch": orch, "registry": registry}
|
||||
|
||||
prompter_live._RegistryHolder.instance = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -384,16 +398,23 @@ def _batch_body() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_creates_and_reaps(confirm_client: dict) -> None:
|
||||
client, orch = confirm_client["client"], confirm_client["orch"]
|
||||
result = {
|
||||
def _batch_result(*, n: int = 2) -> dict[str, Any]:
|
||||
return {
|
||||
"umbrella_task_id": str(uuid4()),
|
||||
"root_subtask_ids": [str(uuid4()), str(uuid4())],
|
||||
"waves": [[0], [1]],
|
||||
"root_subtask_ids": [str(uuid4()) for _ in range(n)],
|
||||
"waves": [[i] for i in range(n)],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_main_pm_route_creates_and_reaps(
|
||||
confirm_client: dict,
|
||||
) -> None:
|
||||
"""A fresh confirm on the "main_pm" route is always terminal → reap."""
|
||||
client, orch = confirm_client["client"], confirm_client["orch"]
|
||||
result = _batch_result()
|
||||
|
||||
class _FakeService:
|
||||
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
|
||||
return result
|
||||
@@ -407,7 +428,70 @@ async def test_confirm_batch_creates_and_reaps(confirm_client: dict) -> None:
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert resp.json() == result
|
||||
assert orch.reaped == ["s1"] # confirm-batch is terminal → reap
|
||||
assert orch.reaped == ["s1"] # confirm-batch main_pm route is terminal → reap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_board_route_parks_session(confirm_client: dict) -> None:
|
||||
"""A fresh confirm on the "board" route keeps the intake agent alive,
|
||||
parked against the umbrella — the batch-shape mirror of the single-draft
|
||||
keep-alive re-draft loop."""
|
||||
client, orch, registry = (
|
||||
confirm_client["client"],
|
||||
confirm_client["orch"],
|
||||
confirm_client["registry"],
|
||||
)
|
||||
registry.open("s1", "intake-1")
|
||||
result = _batch_result()
|
||||
|
||||
class _FakeService:
|
||||
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
|
||||
return result
|
||||
|
||||
body = _batch_body()
|
||||
body["route"] = "board"
|
||||
with patch(
|
||||
"roboco.api.routes.prompter_live.get_prompter_service",
|
||||
lambda _db: _FakeService(),
|
||||
):
|
||||
resp = await client.post("/api/prompter/live/s1/confirm-batch", json=body)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert resp.json() == result
|
||||
assert orch.reaped == [] # parked, not reaped
|
||||
assert registry.get("s1") is not None
|
||||
assert registry.get("s1").task_id == result["umbrella_task_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_redraft_always_reaps(confirm_client: dict) -> None:
|
||||
"""A redraft confirm (``task_id`` set) always reaps, even on the "board"
|
||||
route — parity with a single-draft redraft confirm (never keeps the agent
|
||||
alive a second time; the umbrella already exists)."""
|
||||
client, orch, registry = (
|
||||
confirm_client["client"],
|
||||
confirm_client["orch"],
|
||||
confirm_client["registry"],
|
||||
)
|
||||
registry.open("s1", "intake-1")
|
||||
task_id = uuid4()
|
||||
result = _batch_result(n=1)
|
||||
result["umbrella_task_id"] = str(task_id)
|
||||
|
||||
class _FakeService:
|
||||
async def update_live_batch(self, *_a: Any, **_kw: Any) -> Any:
|
||||
return result
|
||||
|
||||
body = _batch_body()
|
||||
body["route"] = "board"
|
||||
body["task_id"] = str(task_id)
|
||||
with patch(
|
||||
"roboco.api.routes.prompter_live.get_prompter_service",
|
||||
lambda _db: _FakeService(),
|
||||
):
|
||||
resp = await client.post("/api/prompter/live/s1/confirm-batch", json=body)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert resp.json() == result
|
||||
assert orch.reaped == ["s1"] # redraft confirm is always terminal → reap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -463,6 +547,168 @@ async def test_preview_batch_returns_waves_and_does_not_reap(
|
||||
assert orch.reaped == [] # preview creates nothing and leaves the chat alive
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# re-interview — cold redraft: single-task scope vs. MegaTask umbrella recovery.
|
||||
# DB-backed (real task/journal services + composer) with a fake orchestrator,
|
||||
# so the umbrella branch's scope recovery runs for real.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def reinterview_client(
|
||||
db_session: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
orch = _FakeOrchestrator()
|
||||
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
|
||||
|
||||
async def _real_db() -> AsyncIterator[Any]:
|
||||
yield db_session
|
||||
|
||||
ceo = AgentContext(agent_id=uuid4(), role=AgentRole.CEO, team=None, slug="ceo")
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/prompter")
|
||||
app.dependency_overrides[get_db] = _real_db
|
||||
app.dependency_overrides[get_agent_context] = lambda: ceo
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield {"client": client, "orch": orch, "db": db_session}
|
||||
|
||||
|
||||
def _plain_task(ceo_id: Any, **overrides: Any) -> TaskTable:
|
||||
fields: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"title": "Solo task",
|
||||
"description": "A task the board reviewed, up for a redraft round.",
|
||||
"acceptance_criteria": ["done"],
|
||||
"status": TaskStatus.PENDING,
|
||||
"team": Team.BACKEND,
|
||||
"created_by": ceo_id,
|
||||
**overrides,
|
||||
}
|
||||
return TaskTable(**fields)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_interview_umbrella_recovers_scope_and_seeds_batch(
|
||||
reinterview_client: dict,
|
||||
) -> None:
|
||||
"""An umbrella re-interview recovers the multi-repo scope from its
|
||||
root-subtasks (single-project child + cell_projects union child), returns it
|
||||
to the panel, and seeds the batch composer's redraft message."""
|
||||
client, orch, db = (
|
||||
reinterview_client["client"],
|
||||
reinterview_client["orch"],
|
||||
reinterview_client["db"],
|
||||
)
|
||||
project1, ceo_id = await _seed_project_and_ceo(db)
|
||||
project2 = await _seed_second_project(db, ceo_id)
|
||||
project3 = await _seed_second_project(db, ceo_id)
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "Single child",
|
||||
"acceptance_criteria": ["a"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
{
|
||||
"title": "Union child",
|
||||
"acceptance_criteria": ["b"],
|
||||
"the_work": [
|
||||
{"team": "backend", "summary": "s", "project_id": str(project2)},
|
||||
{"team": "frontend", "summary": "s", "project_id": str(project3)},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = await _confirm_board_batch(
|
||||
db, ceo_id, drafts, [project1, project2, project3]
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/prompter/live/re-interview/{result['umbrella_task_id']}", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
body = resp.json()
|
||||
assert body["session_id"]
|
||||
expected = {str(project1), str(project2), str(project3)}
|
||||
assert set(body["project_ids"]) == expected
|
||||
spawn = orch.spawned[0]
|
||||
assert set(spawn["project_ids"]) == expected # multi-project intake scope
|
||||
assert spawn["project_slug"] is None
|
||||
assert spawn["product_id"] is None
|
||||
msg = spawn["initial_message"]
|
||||
assert "propose_batch" in msg # batch-aware seed, not the single-task one
|
||||
assert "Single child" in msg
|
||||
assert "Union child" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_interview_umbrella_400_when_no_recoverable_projects(
|
||||
reinterview_client: dict,
|
||||
) -> None:
|
||||
"""An umbrella with no live project-bearing children cannot re-interview."""
|
||||
client, db = reinterview_client["client"], reinterview_client["db"]
|
||||
_project1, ceo_id = await _seed_project_and_ceo(db)
|
||||
umbrella = _plain_task(
|
||||
ceo_id, title="MegaTask: empty", team=Team.BOARD, batch_id=uuid4()
|
||||
)
|
||||
db.add(umbrella)
|
||||
await db.flush()
|
||||
|
||||
resp = await client.post(f"/api/prompter/live/re-interview/{umbrella.id}", json={})
|
||||
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert "no recoverable projects" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_interview_single_task_branch_unchanged(
|
||||
reinterview_client: dict,
|
||||
) -> None:
|
||||
"""A non-batch task still takes the single-task path: project-slug scope and
|
||||
the single-draft redraft seed."""
|
||||
client, orch, db = (
|
||||
reinterview_client["client"],
|
||||
reinterview_client["orch"],
|
||||
reinterview_client["db"],
|
||||
)
|
||||
project1, ceo_id = await _seed_project_and_ceo(db)
|
||||
task = _plain_task(ceo_id, project_id=project1)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
slug = (await db.get(ProjectTable, project1)).slug
|
||||
|
||||
resp = await client.post(f"/api/prompter/live/re-interview/{task.id}", json={})
|
||||
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert resp.json()["project_ids"] is None # single-task path: no batch scope
|
||||
spawn = orch.spawned[0]
|
||||
assert spawn["project_slug"] == slug
|
||||
assert spawn["product_id"] is None
|
||||
assert spawn["project_ids"] is None
|
||||
msg = spawn["initial_message"]
|
||||
assert "revising an existing task draft" in msg
|
||||
assert "Solo task" in msg
|
||||
assert "propose_batch" not in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_interview_single_task_400_without_scope(
|
||||
reinterview_client: dict,
|
||||
) -> None:
|
||||
"""A non-batch task with neither project nor product still 400s."""
|
||||
client, db = reinterview_client["client"], reinterview_client["db"]
|
||||
_project1, ceo_id = await _seed_project_and_ceo(db)
|
||||
task = _plain_task(ceo_id) # no project_id / product_id / batch_id
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
resp = await client.post(f"/api/prompter/live/re-interview/{task.id}", json={})
|
||||
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert "no project/product scope" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search-tasks — the intake's mid-conversation "have we done this before?" tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ from roboco.models.base import TaskNature, TaskStatus, TaskType
|
||||
from roboco.services.base import NotFoundError
|
||||
from roboco.services.prompter import (
|
||||
PrompterService,
|
||||
compose_batch_redraft_message,
|
||||
compose_redraft_message,
|
||||
format_board_briefing,
|
||||
)
|
||||
@@ -63,6 +64,60 @@ def test_compose_redraft_message_includes_draft_and_brief() -> None:
|
||||
assert "z" in msg
|
||||
|
||||
|
||||
def test_compose_batch_redraft_message_includes_every_child_and_brief() -> None:
|
||||
umbrella = SimpleNamespace(
|
||||
title="MegaTask: Ship the thing",
|
||||
description="Coordinate 2 sequenced tasks as one MegaTask.",
|
||||
)
|
||||
children = [
|
||||
SimpleNamespace(
|
||||
title="Backend piece",
|
||||
description="Backend description.",
|
||||
acceptance_criteria=["backend ac"],
|
||||
project=SimpleNamespace(name="roboco-api"),
|
||||
cell_projects=[],
|
||||
),
|
||||
SimpleNamespace(
|
||||
title="Frontend piece",
|
||||
description="Frontend description.",
|
||||
acceptance_criteria=["frontend ac"],
|
||||
project=None,
|
||||
cell_projects=[
|
||||
SimpleNamespace(
|
||||
team=Team.FRONTEND, project=SimpleNamespace(name="panel-repo")
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
entries = [
|
||||
{
|
||||
"author_role": "product_owner",
|
||||
"author": "po",
|
||||
"title": "PO",
|
||||
"content": "board feedback",
|
||||
}
|
||||
]
|
||||
msg = compose_batch_redraft_message(
|
||||
cast("TaskTable", umbrella), cast("list[TaskTable]", children), entries
|
||||
)
|
||||
assert "Ship the thing" in msg
|
||||
assert "Backend piece" in msg
|
||||
assert "roboco-api" in msg
|
||||
assert "backend ac" in msg
|
||||
assert "Frontend piece" in msg
|
||||
assert "panel-repo" in msg
|
||||
assert "Product Owner" in msg
|
||||
assert "board feedback" in msg
|
||||
assert "propose_batch" in msg
|
||||
|
||||
|
||||
def test_compose_batch_redraft_message_no_children_is_still_valid() -> None:
|
||||
umbrella = SimpleNamespace(title="MegaTask: Empty", description="Nothing yet.")
|
||||
msg = compose_batch_redraft_message(cast("TaskTable", umbrella), [], [])
|
||||
assert "Empty" in msg
|
||||
assert "propose_batch" in msg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# update_live_draft (DB)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
@@ -302,3 +303,137 @@ def test_board_review_prompt_names_both_reviewers_and_board_verbs() -> None:
|
||||
assert "i_am_idle()" in prompt
|
||||
assert "Product Owner" in prompt and "Head of Marketing" in prompt
|
||||
assert "do NOT" in prompt.lower() or "do not" in prompt.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _inject_board_brief_into_parked_intake: single-task vs. MegaTask umbrella
|
||||
# composer choice (the keep-alive re-draft loop's message content).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeParkedSession:
|
||||
def __init__(self, session_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
|
||||
|
||||
class _FakeLiveRegistry:
|
||||
"""Stands in for the live-session registry: one parked session, records
|
||||
every ``deliver`` call so the test can inspect the composed message."""
|
||||
|
||||
def __init__(self, session: _FakeParkedSession | None) -> None:
|
||||
self._session = session
|
||||
self.delivered: list[tuple[str, str]] = []
|
||||
|
||||
def find_by_task(self, _task_id: str) -> _FakeParkedSession | None:
|
||||
return self._session
|
||||
|
||||
async def deliver(self, session_id: str, message: str) -> bool:
|
||||
self.delivered.append((session_id, message))
|
||||
return True
|
||||
|
||||
|
||||
def _patch_inject_seams(
|
||||
task: Any, journal_entries: list[dict[str, Any]], children: list[Any] | None = None
|
||||
) -> tuple[Any, ...]:
|
||||
"""Patch the DB/task/journal seams ``_inject_board_brief_into_parked_intake``
|
||||
opens, mirroring ``_patch_handoff_db``'s shape for this function's own
|
||||
(different) import points."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_ctx() -> AsyncIterator[Any]:
|
||||
yield AsyncMock()
|
||||
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get = AsyncMock(return_value=task)
|
||||
task_svc.get_live_subtasks = AsyncMock(return_value=children or [])
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.board_review_brief = AsyncMock(return_value=journal_entries)
|
||||
return (
|
||||
patch("roboco.db.base.get_db_context", _fake_ctx),
|
||||
patch("roboco.services.task.get_task_service", return_value=task_svc),
|
||||
patch("roboco.services.journal.get_journal_service", return_value=journal_svc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_board_brief_single_task_uses_single_composer() -> None:
|
||||
"""A normal (non-batch) parked task's redraft message uses
|
||||
``compose_redraft_message`` — the umbrella branch must not change it."""
|
||||
orch = _make_orch()
|
||||
task_id = str(uuid4())
|
||||
registry = _FakeLiveRegistry(_FakeParkedSession("live-session-1"))
|
||||
|
||||
task = SimpleNamespace(
|
||||
title="Original task",
|
||||
description="Original description.",
|
||||
acceptance_criteria=["do the thing"],
|
||||
batch_id=None,
|
||||
parent_task_id=None,
|
||||
)
|
||||
db_ctx, task_ctx, journal_ctx = _patch_inject_seams(task, [])
|
||||
with (
|
||||
patch("roboco.services.prompter_live.get_live_registry", return_value=registry),
|
||||
db_ctx,
|
||||
task_ctx,
|
||||
journal_ctx,
|
||||
):
|
||||
await orch._inject_board_brief_into_parked_intake(task_id)
|
||||
|
||||
assert len(registry.delivered) == 1
|
||||
_sid, message = registry.delivered[0]
|
||||
assert "Original task" in message
|
||||
assert "revising an existing task draft" in message
|
||||
assert "propose_batch" not in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_board_brief_batch_umbrella_uses_batch_composer() -> None:
|
||||
"""A parked MegaTask umbrella's redraft message uses
|
||||
``compose_batch_redraft_message``: every root-subtask's snapshot plus the
|
||||
``propose_batch`` re-submit instruction."""
|
||||
orch = _make_orch()
|
||||
task_id = str(uuid4())
|
||||
registry = _FakeLiveRegistry(_FakeParkedSession("live-session-2"))
|
||||
|
||||
umbrella = SimpleNamespace(
|
||||
title="MegaTask: Ship things",
|
||||
description="Coordinate 2 sequenced tasks as one MegaTask.",
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=None,
|
||||
)
|
||||
children = [
|
||||
SimpleNamespace(
|
||||
title="Backend piece",
|
||||
description="Backend description.",
|
||||
acceptance_criteria=["backend ac"],
|
||||
project=SimpleNamespace(name="roboco-api"),
|
||||
cell_projects=[],
|
||||
),
|
||||
]
|
||||
db_ctx, task_ctx, journal_ctx = _patch_inject_seams(umbrella, [], children)
|
||||
with (
|
||||
patch("roboco.services.prompter_live.get_live_registry", return_value=registry),
|
||||
db_ctx,
|
||||
task_ctx,
|
||||
journal_ctx,
|
||||
):
|
||||
await orch._inject_board_brief_into_parked_intake(task_id)
|
||||
|
||||
assert len(registry.delivered) == 1
|
||||
_sid, message = registry.delivered[0]
|
||||
assert "Ship things" in message
|
||||
assert "Backend piece" in message
|
||||
assert "roboco-api" in message
|
||||
assert "propose_batch" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_board_brief_no_parked_session_is_noop() -> None:
|
||||
"""No session parked for the task → no-op, never raises."""
|
||||
orch = _make_orch()
|
||||
registry = _FakeLiveRegistry(None)
|
||||
with patch(
|
||||
"roboco.services.prompter_live.get_live_registry", return_value=registry
|
||||
):
|
||||
await orch._inject_board_brief_into_parked_intake(str(uuid4()))
|
||||
assert registry.delivered == []
|
||||
|
||||
@@ -37,7 +37,7 @@ from roboco.models.base import (
|
||||
)
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services import prompter as prompter_module
|
||||
from roboco.services.base import ServiceError, ValidationError
|
||||
from roboco.services.base import NotFoundError, ServiceError, ValidationError
|
||||
from roboco.services.prompter import (
|
||||
_HISTORY_DIGEST_PER_PROJECT_LIMIT,
|
||||
_HISTORY_TITLE_EXCERPT_CAP,
|
||||
@@ -55,6 +55,7 @@ from roboco.services.prompter import (
|
||||
history_digest_layer,
|
||||
parse_readiness,
|
||||
)
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
# =============================================================================
|
||||
# Pure function tests (no DB)
|
||||
@@ -914,6 +915,560 @@ async def test_confirm_live_batch_strips_assigned_to_from_drafts(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MegaTask redraft: update_live_batch (board-review keep-alive loop, batch shape)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _confirm_board_batch(
|
||||
db_session: Any,
|
||||
ceo_id: UUID,
|
||||
drafts: list[dict[str, Any]],
|
||||
project_ids: list[UUID],
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm a board-routed MegaTask (held root-subtasks) to redraft against."""
|
||||
service = get_prompter_service(db=db_session)
|
||||
with patch("roboco.services.prompter.redis.from_url", return_value=_FakeRedis()):
|
||||
return await service.confirm_live_batch(
|
||||
"Seed batch",
|
||||
drafts,
|
||||
ceo_id,
|
||||
project_ids=project_ids,
|
||||
route="board",
|
||||
session_id=f"sess-{uuid4().hex}",
|
||||
)
|
||||
|
||||
|
||||
def _two_item_drafts(project1: UUID, project2: UUID) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"title": "One",
|
||||
"acceptance_criteria": ["x"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
{
|
||||
"title": "Two",
|
||||
"acceptance_criteria": ["y"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project2),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_patches_unchanged_scope_in_place(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""A redraft that keeps every item's project targets patches title/
|
||||
description/acceptance criteria in place — no child is replaced."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
before_ids = [UUID(sid) for sid in result["root_subtask_ids"]]
|
||||
|
||||
revised = [
|
||||
{**drafts[0], "title": "One revised", "acceptance_criteria": ["x2"]},
|
||||
{**drafts[1], "title": "Two revised", "acceptance_criteria": ["y2"]},
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
out = await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]), "Seed batch", revised, ceo_id, route="board"
|
||||
)
|
||||
|
||||
assert [UUID(sid) for sid in out["root_subtask_ids"]] == before_ids
|
||||
child0 = await db_session.get(TaskTable, before_ids[0])
|
||||
child1 = await db_session.get(TaskTable, before_ids[1])
|
||||
assert child0.title == "One revised"
|
||||
assert child0.acceptance_criteria == ["x2"]
|
||||
assert child0.status == TaskStatus.BACKLOG # untouched, still board-held
|
||||
assert child1.title == "Two revised"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_grows_creates_extra_child(db_session: Any) -> None:
|
||||
"""More drafts than existing root-subtasks creates the extras, BACKLOG."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
|
||||
grown = [
|
||||
*drafts,
|
||||
{
|
||||
"title": "Three",
|
||||
"acceptance_criteria": ["z"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
out = await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]), "Seed batch", grown, ceo_id, route="board"
|
||||
)
|
||||
|
||||
assert len(out["root_subtask_ids"]) == len(grown)
|
||||
new_child = await db_session.get(TaskTable, UUID(out["root_subtask_ids"][2]))
|
||||
assert new_child.title == "Three"
|
||||
assert new_child.status == TaskStatus.BACKLOG
|
||||
assert new_child.parent_task_id == UUID(result["umbrella_task_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_shrinks_cancels_surplus_child(db_session: Any) -> None:
|
||||
"""Fewer drafts than existing root-subtasks cancels the surplus."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = [
|
||||
*_two_item_drafts(project1, project2),
|
||||
{
|
||||
"title": "Three",
|
||||
"acceptance_criteria": ["z"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
]
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
surplus_id = UUID(result["root_subtask_ids"][2])
|
||||
kept_drafts = drafts[:2]
|
||||
|
||||
service = get_prompter_service(db=db_session)
|
||||
out = await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]),
|
||||
"Seed batch",
|
||||
kept_drafts,
|
||||
ceo_id,
|
||||
route="board",
|
||||
)
|
||||
|
||||
assert len(out["root_subtask_ids"]) == len(kept_drafts)
|
||||
surplus = await db_session.get(TaskTable, surplus_id)
|
||||
assert surplus.status == TaskStatus.CANCELLED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_scope_change_replaces_child(db_session: Any) -> None:
|
||||
"""A revised draft that moves to a different project cancels the stale
|
||||
child and creates a replacement — never patches a scope change in place."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
project3 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
original_second_id = UUID(result["root_subtask_ids"][1])
|
||||
|
||||
revised = [
|
||||
drafts[0],
|
||||
{
|
||||
"title": "Two moved",
|
||||
"acceptance_criteria": ["y2"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project3),
|
||||
},
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
# project3 is new to the batch — the panel round-trips the widened scope.
|
||||
out = await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]),
|
||||
"Seed batch",
|
||||
revised,
|
||||
ceo_id,
|
||||
project_ids=[project1, project2, project3],
|
||||
route="board",
|
||||
)
|
||||
|
||||
new_second_id = UUID(out["root_subtask_ids"][1])
|
||||
assert new_second_id != original_second_id
|
||||
original = await db_session.get(TaskTable, original_second_id)
|
||||
assert original.status == TaskStatus.CANCELLED
|
||||
replacement = await db_session.get(TaskTable, new_second_id)
|
||||
assert replacement.project_id == project3
|
||||
assert replacement.title == "Two moved"
|
||||
assert replacement.status == TaskStatus.BACKLOG
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_rewires_dependency_edges(db_session: Any) -> None:
|
||||
"""Old sibling dependency edges are cleared and the fresh wave plan's edges
|
||||
are wired — a redraft never leaves a stale edge from the prior sequencing."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "A: add table",
|
||||
"acceptance_criteria": ["a"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
"intends_to_touch": ["roboco/services/foo.py"],
|
||||
"adds_migration": True,
|
||||
},
|
||||
{
|
||||
"title": "B: extend table",
|
||||
"acceptance_criteria": ["b"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
"intends_to_touch": ["roboco/services/bar.py"],
|
||||
"adds_migration": True,
|
||||
},
|
||||
{
|
||||
"title": "C: frontend widget",
|
||||
"acceptance_criteria": ["c"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project2),
|
||||
"intends_to_touch": ["panel/src/widget.tsx"],
|
||||
},
|
||||
]
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
a_id, b_id, c_id = (UUID(sid) for sid in result["root_subtask_ids"])
|
||||
b = await db_session.get(TaskTable, b_id)
|
||||
assert a_id in b.dependency_ids # original chain: B waits on A (migrations)
|
||||
|
||||
# Neither A nor B adds a migration anymore; C now explicitly waits on A.
|
||||
revised: list[dict[str, Any]] = [
|
||||
{**drafts[0], "adds_migration": False},
|
||||
{**drafts[1], "adds_migration": False},
|
||||
{**drafts[2], "depends_on": [0]},
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]), "Seed batch", revised, ceo_id, route="board"
|
||||
)
|
||||
|
||||
c = await db_session.get(TaskTable, c_id)
|
||||
assert a_id not in b.dependency_ids # stale edge cleared
|
||||
assert a_id in c.dependency_ids # fresh edge applied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_board_route_resets_review_flag(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""route='board' sends the redrafted umbrella back for another review round."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
umbrella = await db_session.get(TaskTable, umbrella_id)
|
||||
umbrella.board_review_complete = True
|
||||
await db_session.flush()
|
||||
|
||||
service = get_prompter_service(db=db_session)
|
||||
await service.update_live_batch(
|
||||
umbrella_id, "Seed batch", drafts, ceo_id, route="board"
|
||||
)
|
||||
|
||||
assert umbrella.board_review_complete is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_main_pm_route_approves_and_activates(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""route='main_pm' hands the umbrella to Main PM and releases its BACKLOG
|
||||
root-subtasks to PENDING (approve_and_start's existing batch-activation)."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
# merge() with the fixed AGENT_UUIDS id: idempotent whether or not another
|
||||
# test already committed this row on the shared session-scoped test DB
|
||||
# (mirrors ``_seed_project_and_ceo``'s product-owner/main-pm upsert above).
|
||||
main_pm = await db_session.merge(
|
||||
AgentTable(
|
||||
id=UUID(AGENT_UUIDS["main-pm"]),
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
umbrella = await db_session.get(TaskTable, umbrella_id)
|
||||
umbrella.board_review_complete = True
|
||||
await db_session.flush()
|
||||
|
||||
service = get_prompter_service(db=db_session)
|
||||
out = await service.update_live_batch(
|
||||
umbrella_id, "Seed batch", drafts, ceo_id, route="main_pm"
|
||||
)
|
||||
|
||||
assert umbrella.assigned_to == main_pm.id
|
||||
assert umbrella.team == Team.MAIN_PM
|
||||
child = await db_session.get(TaskTable, UUID(out["root_subtask_ids"][0]))
|
||||
assert child.status == TaskStatus.PENDING # released by approve_and_start
|
||||
assert child.team == Team.MAIN_PM
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_refuses_when_child_not_backlog(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""A root-subtask already past BACKLOG (claimed/dispatched) refuses the
|
||||
whole redraft — in-place mutation of live work would corrupt it."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
child = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][0]))
|
||||
child.status = TaskStatus.PENDING
|
||||
await db_session.flush()
|
||||
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(ValidationError, match="BACKLOG"):
|
||||
await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]),
|
||||
"Seed batch",
|
||||
drafts,
|
||||
ceo_id,
|
||||
route="board",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_refuses_non_umbrella_task(db_session: Any) -> None:
|
||||
"""A task_id that isn't a batch umbrella (e.g. a root-subtask) is refused."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
root_subtask_id = UUID(result["root_subtask_ids"][0])
|
||||
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(ValidationError, match="umbrella"):
|
||||
await service.update_live_batch(
|
||||
root_subtask_id, "Seed batch", drafts, ceo_id, route="board"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_refuses_unknown_task(db_session: Any) -> None:
|
||||
"""A task_id that doesn't exist at all raises NotFoundError, not a crash."""
|
||||
_project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.update_live_batch(
|
||||
uuid4(),
|
||||
"Seed batch",
|
||||
[{"title": "x", "acceptance_criteria": ["a"]}],
|
||||
ceo_id,
|
||||
route="board",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_round2_after_shrink_succeeds(db_session: Any) -> None:
|
||||
"""A round-1 shrink leaves a CANCELLED child; a round-2 redraft must ignore
|
||||
it — not be refused by the backlog gate, not mismatch positionally."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = [
|
||||
*_two_item_drafts(project1, project2),
|
||||
{
|
||||
"title": "Three",
|
||||
"acceptance_criteria": ["z"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
]
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
service = get_prompter_service(db=db_session)
|
||||
|
||||
# Round 1: shrink to 2 → cancels the third child.
|
||||
round1 = await service.update_live_batch(
|
||||
umbrella_id, "Seed batch", drafts[:2], ceo_id, route="board"
|
||||
)
|
||||
# Round 2: revise the surviving 2 in place — must not see the cancelled row.
|
||||
revised = [
|
||||
{**drafts[0], "title": "One round-2"},
|
||||
{**drafts[1], "title": "Two round-2"},
|
||||
]
|
||||
round2 = await service.update_live_batch(
|
||||
umbrella_id, "Seed batch", revised, ceo_id, route="board"
|
||||
)
|
||||
|
||||
assert round2["root_subtask_ids"] == round1["root_subtask_ids"] # in-place
|
||||
child0 = await db_session.get(TaskTable, UUID(round2["root_subtask_ids"][0]))
|
||||
assert child0.title == "One round-2"
|
||||
cancelled = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][2]))
|
||||
assert cancelled.status == TaskStatus.CANCELLED # untouched by round 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_round2_after_scope_change_succeeds(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""A round-1 scope change leaves a CANCELLED child mid-list; round 2 must
|
||||
match drafts positionally against only the LIVE children."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
project3 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
service = get_prompter_service(db=db_session)
|
||||
|
||||
# Round 1: move the second draft to project3 → old child cancelled, replaced.
|
||||
moved = {
|
||||
"title": "Two moved",
|
||||
"acceptance_criteria": ["y2"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project3),
|
||||
}
|
||||
round1 = await service.update_live_batch(
|
||||
umbrella_id,
|
||||
"Seed batch",
|
||||
[drafts[0], moved],
|
||||
ceo_id,
|
||||
project_ids=[project1, project2, project3],
|
||||
route="board",
|
||||
)
|
||||
replacement_id = UUID(round1["root_subtask_ids"][1])
|
||||
|
||||
# Round 2: same scopes → both live children patched in place (the cancelled
|
||||
# original must not shift the positional pairing).
|
||||
round2 = await service.update_live_batch(
|
||||
umbrella_id,
|
||||
"Seed batch",
|
||||
[{**drafts[0], "title": "One round-2"}, {**moved, "title": "Two round-2"}],
|
||||
ceo_id,
|
||||
project_ids=[project1, project2, project3],
|
||||
route="board",
|
||||
)
|
||||
|
||||
assert UUID(round2["root_subtask_ids"][1]) == replacement_id # in-place
|
||||
replacement = await db_session.get(TaskTable, replacement_id)
|
||||
assert replacement.title == "Two round-2"
|
||||
assert replacement.status == TaskStatus.BACKLOG
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_rejects_single_project_collapse(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""A redraft whose drafts all target one project is refused — same
|
||||
MegaTask-shape gate as the create path."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
|
||||
collapsed = [
|
||||
{**drafts[0]},
|
||||
{**drafts[1], "project_id": str(project1)}, # both on project1 now
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(ValidationError, match="at least two distinct projects"):
|
||||
await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]),
|
||||
"Seed batch",
|
||||
collapsed,
|
||||
ceo_id,
|
||||
project_ids=[project1, project2],
|
||||
route="board",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_live_batch_rejects_out_of_scope_draft(db_session: Any) -> None:
|
||||
"""A redraft draft targeting a project outside the scoped set is refused —
|
||||
same scope gate as the create path (scope derived from the live children
|
||||
when the caller passes none)."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts = _two_item_drafts(project1, project2)
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2]
|
||||
)
|
||||
|
||||
drifted = [
|
||||
drafts[0],
|
||||
{**drafts[1], "project_id": str(uuid4())}, # never in scope
|
||||
]
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(ValidationError, match="outside this MegaTask"):
|
||||
await service.update_live_batch(
|
||||
UUID(result["umbrella_task_id"]),
|
||||
"Seed batch",
|
||||
drifted,
|
||||
ceo_id,
|
||||
route="board",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distinct_projects_for_batch_unions_cells_and_skips_cancelled(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""Scope recovery unions each live child's project_id + cell_projects rows;
|
||||
a cancelled child's repo does not resurrect."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
project3 = await _seed_second_project(db_session, ceo_id)
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "Single-project child",
|
||||
"acceptance_criteria": ["a"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
{
|
||||
"title": "Multi-cell child",
|
||||
"acceptance_criteria": ["b"],
|
||||
"the_work": [
|
||||
{"team": "backend", "summary": "s", "project_id": str(project2)},
|
||||
{"team": "frontend", "summary": "s", "project_id": str(project3)},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = await _confirm_board_batch(
|
||||
db_session, ceo_id, drafts, [project1, project2, project3]
|
||||
)
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
task_service = get_task_service(db_session)
|
||||
|
||||
recovered = await task_service.distinct_projects_for_batch(umbrella_id)
|
||||
assert set(recovered) == {project1, project2, project3}
|
||||
|
||||
# Cancel the single-project child — its repo drops out of the recovery.
|
||||
await task_service.cancel(UUID(result["root_subtask_ids"][0]), agent_role="main_pm")
|
||||
recovered = await task_service.distinct_projects_for_batch(umbrella_id)
|
||||
assert set(recovered) == {project2, project3}
|
||||
|
||||
|
||||
def test_preview_batch_computes_waves_without_creating() -> None:
|
||||
"""preview_batch is pure: it returns the same waves confirm would wire, with
|
||||
no DB session and no task creation."""
|
||||
|
||||
Reference in New Issue
Block a user