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:
Renzo F
2026-07-10 09:38:10 +02:00
committed by GitHub
co-authored by Renn F
parent 3674a1e002
commit bba20a3917
13 changed files with 1569 additions and 69 deletions
+135
View File
@@ -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 == []