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:
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user