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
+2
View File
@@ -425,6 +425,8 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Intake + create path.** The intake chat can be scoped to a **MegaTask** (a multi-project picker → `StartLiveRequest.project_ids`); the orchestrator clones each repo (`_clone_intake_scope` / `_slugs_for_project_ids`, the multi-repo machinery products already used). The intake agent proposes the whole batch with one **`propose_batch`** tool call — wired on both runtimes (the Claude SDK driver emits one `batch` stream chunk; the grok `intake_server` POSTs a `batch` relay event). The panel's third intake scope accumulates it into a Review-MegaTask card → `POST /prompter/live/{session}/confirm-batch`. `PrompterService.confirm_live_batch` builds the umbrella + N root-subtasks (via `create_task_from_draft` + a `BatchPlacement`) and wires the analyzer edges through `add_dependency`. The Board route holds the root-subtasks in BACKLOG until `approve_and_start` releases them (`_activate_batch_root_subtasks`); the Main-PM route dispatches wave 0 at once. The Product Owner + Head of Marketing review the whole batch (their identity prompts carry a MegaTask section).
**Board-review → redraft loop (batch parity).** A first board-route batch confirm PARKS the intake session against the umbrella instead of reaping it — the same keep-alive loop single drafts get. When both board reviewers finish, the orchestrator injects a batch-aware brief into the still-live chat (`_compose_parked_intake_redraft``compose_batch_redraft_message`: every live root-subtask's snapshot + the board's decision notes + an explicit one-`propose_batch`-call re-proposal instruction); the revised batch re-confirms with `BatchConfirmRequest.task_id` set, which routes to `PrompterService.update_live_batch` — an in-place update, not a new batch: umbrella prose re-composed, live root-subtasks positionally patched (cancel+recreate only on a per-item scope change; create/cancel on count changes), dependency edges rewired to the fresh wave plan, and the create path's `_validate_batch_scope` gate re-applied so a redraft can't collapse the batch to one project or drift outside the scoped repos. Every reader uses the CANCELLED-excluding `get_live_subtasks` view, so multi-round redrafts survive earlier cancels. The cold fallback (`POST /prompter/live/re-interview/{task_id}`, the task-detail "Re-draft with board feedback" button) now handles the branchless umbrella by recovering its multi-repo scope from the live children (`TaskService.distinct_projects_for_batch`) and returning `project_ids` so the panel re-enters batch mode. On the re-confirm, `route="main_pm"` approves-and-starts (releasing the BACKLOG children) and `route="board"` clears `board_review_complete` for another review round; a redraft re-confirm always reaps (parity with single drafts — later rounds ride the cold path). Panel side: `confirmBatch`'s board branch keeps the chat open and threads `batchRedraftTaskIdRef` (persisted with the chat) into the next confirm.
## Services
Core services in `roboco/services/`:
+68 -8
View File
@@ -404,6 +404,9 @@ interface PersistedChat {
};
editableDraft: EditableDraft;
redraftTaskId?: string | null;
// Set when this batch chat is a board-informed MegaTask re-draft: mirrors
// redraftTaskId but for confirmBatch (the umbrella id, not a single task).
batchRedraftTaskId?: string | null;
// MegaTask review state, so a reload mid-batch-review restores the batch.
batch?: BatchProposal | null;
batchWaves?: number[][] | null;
@@ -487,6 +490,10 @@ export function usePrompter() {
// Set when this chat is a board-informed re-draft of an existing task: confirm
// then updates that task in place instead of creating a new one.
const redraftTaskIdRef = useRef<string | null>(null);
// Same idea, for a MegaTask: set once a batch is sent to the board (or a
// batch cold-redraft is opened), so confirmBatch updates the umbrella +
// root-subtasks in place instead of creating a new batch.
const batchRedraftTaskIdRef = useRef<string | null>(null);
const sourceRef = useRef<EventSource | null>(null);
const streamingIdRef = useRef<string | null>(null);
// Synchronous re-entry guard for launch — a double-click was creating two tasks.
@@ -740,6 +747,7 @@ export function usePrompter() {
scope: scopeRef.current,
editableDraft,
redraftTaskId: redraftTaskIdRef.current,
batchRedraftTaskId: batchRedraftTaskIdRef.current,
batch,
batchWaves,
savedAt: Date.now(),
@@ -779,6 +787,7 @@ export function usePrompter() {
// so land in a stable state rather than "streaming".
sessionIdRef.current = persisted.sessionId;
redraftTaskIdRef.current = persisted.redraftTaskId ?? null;
batchRedraftTaskIdRef.current = persisted.batchRedraftTaskId ?? null;
setSessionId(persisted.sessionId);
setMessages(persisted.messages);
setEditableDraft(persisted.editableDraft);
@@ -874,8 +883,11 @@ export function usePrompter() {
setState("preparing");
try {
// Scope the chat to the task's product/project so launch has a target
// even if the re-drafted proposal omits it.
// even if the re-drafted proposal omits it. A MegaTask umbrella has
// neither (it's branchless) — that's how a batch redraft is detected.
const task = await tasksApi.get(taskId);
const isBatchUmbrella =
!task.project_id && !task.product_id && !!task.batch_id;
if (task.product_id) {
setTargetKind("product");
setProductId(task.product_id);
@@ -883,11 +895,20 @@ export function usePrompter() {
setTargetKind("project");
setProjectId(task.project_id);
}
const { session_id } = await prompterLiveApi.reInterview(taskId);
redraftTaskIdRef.current = taskId;
sessionIdRef.current = session_id;
setSessionId(session_id);
openStream(session_id);
const response = await prompterLiveApi.reInterview(taskId);
if (isBatchUmbrella) {
// The umbrella carries no project scope of its own — seed the
// MegaTask's multi-project scope from the recovered response so
// confirmBatch's scope validation has something to check against.
setTargetKind("megatask");
setProjectIds(response.project_ids ?? []);
batchRedraftTaskIdRef.current = taskId;
} else {
redraftTaskIdRef.current = taskId;
}
sessionIdRef.current = response.session_id;
setSessionId(response.session_id);
openStream(response.session_id);
setIsSending(true);
setActivity("Re-opening intake with the board's feedback…");
setState("streaming");
@@ -1157,17 +1178,46 @@ export function usePrompter() {
launchingRef.current = true;
setIsLaunching(true);
setState("launching");
// A board-informed re-draft (either parked from a first pass or opened
// cold via startRedraft) updates the existing umbrella in place.
const redraftId = batchRedraftTaskIdRef.current;
try {
const result = await prompterLiveApi.confirmBatch(sid, {
title: batch.title.trim() || "MegaTask",
drafts: batch.drafts,
project_ids: scopeRef.current.projectIds,
route,
...(redraftId ? { task_id: redraftId } : {}),
});
// Board route, first pass (not a redraft re-confirm): the backend
// parks the intake agent so the board's feedback can be injected for
// an in-place batch re-draft. Keep the chat alive — mirrors
// launchTask's single-draft board branch.
if (route === "board" && !redraftId) {
batchRedraftTaskIdRef.current = result.umbrella_task_id;
setBatch(null);
setBatchWaves(null);
addMessage({
role: "assistant",
content:
"Sent to the board — the Product Owner and Head of Marketing are " +
"reviewing this MegaTask. Their feedback will arrive here as a " +
"revised batch you can approve. You can leave and come back; this " +
"chat stays open.",
});
setState("chatting");
return; // `finally` resets the launching guard
}
// Terminal: a straight Main-PM launch, or a redraft re-confirm of
// either route — the backend reaps the session once task_id is set,
// parity with the single-draft path.
closeStream();
void prompterLiveApi.stop(sid).catch(() => undefined);
clearPersisted();
sessionIdRef.current = null;
batchRedraftTaskIdRef.current = null;
setBatchResult(result);
setCreatedTaskId(result.umbrella_task_id);
setCreatedTaskTitle(batch.title.trim() || "MegaTask");
@@ -1192,14 +1242,23 @@ export function usePrompter() {
}
setState("success");
} catch (err) {
toast.error(`Failed to launch MegaTask: ${getErrorMessage(err)}`);
// fastapi-guard middleware rejections (rate-limit / content-type /
// honeypot) put `message` at the top level, not under `detail` —
// getErrorMessage only reads `detail`, so without this a guard
// rejection surfaces axios's generic "Request failed with status
// code N" instead of the real reason.
const guarded = (err as { response?: { data?: { message?: string } } })
.response?.data?.message;
toast.error(
`Failed to launch MegaTask: ${guarded ?? getErrorMessage(err)}`,
);
setState("batch_preview");
} finally {
setIsLaunching(false);
launchingRef.current = false;
}
},
[batch, closeStream],
[batch, closeStream, addMessage],
);
// -----------------------------------------------------------------------
@@ -1213,6 +1272,7 @@ export function usePrompter() {
clearPersisted();
sessionIdRef.current = null;
streamingIdRef.current = null;
batchRedraftTaskIdRef.current = null;
setMessages([]);
setSessionId(null);
setActivity(null);
+7 -1
View File
@@ -32,6 +32,10 @@ export interface StartLivePayload {
export interface StartLiveResponse {
session_id: string;
// Present only from re-interview on a MegaTask umbrella: the recovered
// multi-project scope (the umbrella itself carries no project_id/product_id
// to seed the redraft chat from).
project_ids?: string[];
}
/** Event kinds the container relays — mirrors the backend driver.StreamChunk. */
@@ -78,7 +82,9 @@ export const prompterLiveApi = {
},
/** Re-open intake to re-draft a board-reviewed task with the board's feedback.
* Spawns a fresh session seeded with the current draft + the board review. */
* Spawns a fresh session seeded with the current draft + the board review.
* For a MegaTask umbrella, the response's `project_ids` carries the batch's
* recovered multi-project scope. */
reInterview: async (taskId: string): Promise<StartLiveResponse> => {
const { data } = await api.post<StartLiveResponse>(
`/prompter/live/re-interview/${taskId}`,
+3
View File
@@ -65,6 +65,9 @@ export interface BatchConfirmPayload {
drafts: DraftProposal[];
project_ids: string[];
route?: "board" | "main_pm";
// Set on a board-informed MegaTask re-draft: confirm updates the existing
// umbrella + root-subtasks in place instead of creating a new batch.
task_id?: string;
}
/** The backend's MegaTask create result: the umbrella, its root-subtasks, and
+86 -13
View File
@@ -265,28 +265,54 @@ async def confirm_live_batch(
db: DbSession,
agent: CurrentAgentContext,
) -> dict[str, Any]:
"""Turn the agent's confirmed MegaTask (N drafts) into a sequenced batch, reap.
"""Turn the agent's confirmed MegaTask (N drafts) into a sequenced batch.
Builds the branchless umbrella + one root-subtask per draft, wires the
collision-derived dependency waves, and routes the umbrella per ``route``
(Board review vs. straight to the Main PM) each root-subtask keeps its own
project / branch / PR. Returns the umbrella id, the root-subtask ids, and the
computed waves + warnings for the panel. Always terminal the live session
is reaped once the drafts are tasks.
computed waves + warnings for the panel.
``task_id`` set is a board-informed re-draft: updates the existing MegaTask
umbrella + root-subtasks in place instead of creating a new batch (mirrors
``confirm_live``'s single-task re-draft), and always reaps. On a first
confirm (``task_id`` None), the "Board review & Start" route parks the
intake agent against the umbrella instead of reaping same keep-alive
re-draft loop as a single-task confirm so the board's feedback can be
injected in-context; every other path reaps once the drafts are tasks.
"""
service = get_prompter_service(db)
try:
result = await service.confirm_live_batch(
body.title,
body.drafts,
agent.agent_id,
project_ids=body.project_ids,
route=body.route,
session_id=session_id,
)
if body.task_id is not None:
result = await service.update_live_batch(
body.task_id,
body.title,
body.drafts,
agent.agent_id,
project_ids=body.project_ids,
route=body.route,
agent_role=str(agent.role.value),
)
else:
result = await service.confirm_live_batch(
body.title,
body.drafts,
agent.agent_id,
project_ids=body.project_ids or [],
route=body.route,
session_id=session_id,
)
except ServiceError as e:
raise _translate_service_error(e) from e
await db.commit()
if (
body.task_id is None
and body.route == "board"
and get_live_registry().park(session_id, result["umbrella_task_id"])
):
return result
await get_orchestrator().reap_intake_session(session_id)
return result
@@ -305,6 +331,45 @@ async def _intake_scope_for_task(
return None, None
async def _start_batch_re_interview(
db: DbSession, umbrella: Any, entries: list[dict[str, Any]]
) -> StartLiveResponse:
"""Cold re-interview for a MegaTask umbrella.
Recovers the batch's multi-repo scope from its root-subtasks' own project /
cell-map targets (no single project/product lives on the branchless
umbrella) and seeds a batch-aware redraft message. 400 only when nothing is
recoverable (e.g. every root-subtask was itself cancelled).
"""
from roboco.services.prompter import compose_batch_redraft_message
from roboco.services.task import get_task_service
task_service = get_task_service(db)
umbrella_id = UUID(str(umbrella.id))
children = await task_service.get_live_subtasks(umbrella_id)
project_ids = await task_service.distinct_projects_for_batch(umbrella_id)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This MegaTask has no recoverable projects to re-interview against.",
)
initial_message = compose_batch_redraft_message(umbrella, children, entries)
session_id = uuid4().hex
try:
await get_orchestrator().start_intake_session(
session_id,
project_ids=[str(pid) for pid in project_ids],
initial_message=initial_message,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start re-interview session: {exc}",
) from exc
return StartLiveResponse(session_id=session_id, project_ids=project_ids)
@router.post(
"/live/re-interview/{task_id}",
response_model=StartLiveResponse,
@@ -322,7 +387,12 @@ async def re_interview(
panel opens its stream and, on confirm, passes ``task_id`` so the revised
draft updates this task instead of creating a new one. This is the cold path
(and the resilience fallback for the keep-alive re-draft).
A MegaTask umbrella takes a separate branch (``_start_batch_re_interview``):
it carries no project/product of its own, so its scope is recovered from
its root-subtasks instead.
"""
from roboco.foundation.policy.batch import is_batch_umbrella
from roboco.services.journal import get_journal_service
from roboco.services.prompter import compose_redraft_message
from roboco.services.task import get_task_service
@@ -333,14 +403,17 @@ async def re_interview(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found"
)
entries = await get_journal_service(db).board_review_brief(task_id)
if is_batch_umbrella(batch_id=task.batch_id, parent_task_id=task.parent_task_id):
return await _start_batch_re_interview(db, task, entries)
project_slug, product_id = await _intake_scope_for_task(db, task)
if not project_slug and not product_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Task has no project/product scope to re-interview against.",
)
entries = await get_journal_service(db).board_review_brief(task_id)
initial_message = compose_redraft_message(task, entries)
session_id = uuid4().hex
+30 -2
View File
@@ -11,6 +11,11 @@ from uuid import UUID # noqa: TC003 — pydantic resolves these annotations at
from pydantic import BaseModel, Field, model_validator
# A MegaTask must span at least this many distinct projects (mirrors
# ``PrompterService._MIN_MEGATASK_PROJECTS``) — enforced here only for the
# create path; a redraft recovers each item's scope from its own draft.
_MIN_MEGATASK_PROJECTS = 2
class StartLiveRequest(BaseModel):
"""Open a live intake chat scoped to a project, a product, or a MegaTask.
@@ -38,9 +43,15 @@ class StartLiveRequest(BaseModel):
class StartLiveResponse(BaseModel):
"""The new session's id — the panel opens its stream and posts messages to it."""
"""The new session's id — the panel opens its stream and posts messages to it.
``project_ids`` is set on a MegaTask cold re-interview (the umbrella's
recovered multi-repo scope) so the panel can enter batch mode; None on
every other start path.
"""
session_id: str
project_ids: list[UUID] | None = None
class LiveMessageRequest(BaseModel):
@@ -99,8 +110,25 @@ class BatchConfirmRequest(BaseModel):
drafts: list[dict[str, Any]] = Field(..., min_length=1)
# The scoped repos the MegaTask spans (the set the intake agent read). Every
# draft must target one of these, and the batch must span at least two.
project_ids: list[UUID] = Field(..., min_length=2)
# Required only when creating (``task_id`` unset) — a board-informed
# redraft recovers each item's scope from its own draft instead.
project_ids: list[UUID] | None = None
route: Literal["board", "main_pm"] = "board"
# Set on a board-informed re-draft: confirm updates this existing MegaTask
# umbrella in place instead of creating a new one (mirrors
# ``LiveConfirmRequest.task_id``).
task_id: UUID | None = None
@model_validator(mode="after")
def _project_ids_required_for_create(self) -> BatchConfirmRequest:
if self.task_id is not None:
return self
if self.project_ids is None or len(self.project_ids) < _MIN_MEGATASK_PROJECTS:
raise ValueError(
"project_ids must include at least two projects when creating "
"a MegaTask"
)
return self
class BatchPreviewRequest(BaseModel):
+32 -12
View File
@@ -11381,6 +11381,35 @@ Start now: evidence(task_id="{task_id}")
# in-context. Best-effort; the cold "Re-draft" path covers the rest.
await self._inject_board_brief_into_parked_intake(task_id)
async def _compose_parked_intake_redraft(
self, db: "AsyncSession", task_id: str
) -> str | None:
"""The redraft seed message for a parked intake session, or None if the
task is gone. A MegaTask umbrella gets its batch-aware composer (every
LIVE root-subtask's snapshot); a normal task gets the single-task one.
"""
from uuid import UUID
from roboco.foundation.policy.batch import is_batch_umbrella
from roboco.services.journal import get_journal_service
from roboco.services.prompter import (
compose_batch_redraft_message,
compose_redraft_message,
)
from roboco.services.task import get_task_service
task_service = get_task_service(db)
task = await task_service.get(UUID(task_id))
if task is None:
return None
entries = await get_journal_service(db).board_review_brief(UUID(task_id))
if is_batch_umbrella(
batch_id=task.batch_id, parent_task_id=task.parent_task_id
):
children = await task_service.get_live_subtasks(UUID(task_id))
return compose_batch_redraft_message(task, children, entries)
return compose_redraft_message(task, entries)
async def _inject_board_brief_into_parked_intake(self, task_id: str) -> None:
"""Inject the board's review into a parked intake session, if one exists.
@@ -11393,22 +11422,13 @@ Start now: evidence(task_id="{task_id}")
session = get_live_registry().find_by_task(task_id)
if session is None:
return
from uuid import UUID
from roboco.db.base import get_db_context
from roboco.services.journal import get_journal_service
from roboco.services.prompter import compose_redraft_message
from roboco.services.task import get_task_service
try:
async with get_db_context() as db:
task = await get_task_service(db).get(UUID(task_id))
if task is None:
return
entries = await get_journal_service(db).board_review_brief(
UUID(task_id)
)
message = compose_redraft_message(task, entries)
message = await self._compose_parked_intake_redraft(db, task_id)
if message is None:
return
delivered = await get_live_registry().deliver(session.session_id, message)
logger.info(
"Injected board feedback into parked intake",
+307 -23
View File
@@ -356,29 +356,9 @@ class PrompterService:
# the task always carries a freshly-composed, consistent description.
draft_data["description"] = compose_description(draft_data)
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
# The ad-hoc per-cell map: ≥2 cells → multi-cell root-subtask (cell map
# shape, no project/product); 1 cell → single-project (use that project);
# 0 → fall back to the top-level project_id (single-cell legacy).
cell_map = _draft_cell_map(draft_data)
cell_projects: list[ProductCellMapping] = []
if len(cell_map) >= _MULTI_CELL_MIN:
cell_projects = [
ProductCellMapping(team=team, project_id=pid) for team, pid in cell_map
]
resolved_project_id = None
resolved_product_id = None
elif (
len(cell_map) == 1
and resolved_project_id is None
and resolved_product_id is None
):
# A lone the_work cell with no top-level target → single-project
# task on that cell's project. A top-level project_id/product_id
# wins over a redundant 1-cell map — the explicit target is
# preserved, not silently dropped (#57).
resolved_project_id = cell_map[0][1]
resolved_project_id, resolved_product_id, cell_projects = _resolve_draft_scope(
draft_data
)
self._validate_draft_target(
resolved_project_id,
resolved_product_id,
@@ -912,6 +892,209 @@ class PrompterService:
)
return task_id
async def _require_batch_umbrella(self, task_id: UUID) -> TaskTable:
"""The umbrella row for ``task_id``, or raise (not found / not a batch)."""
from roboco.services.task import get_task_service
umbrella = await get_task_service(self._session).get(task_id)
if umbrella is None:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
if not is_batch_umbrella(
batch_id=umbrella.batch_id, parent_task_id=umbrella.parent_task_id
):
raise ValidationError(
message="update_live_batch requires a MegaTask umbrella task_id.",
field="task_id",
)
return umbrella
@staticmethod
def _require_children_backlog(children: list[TaskTable]) -> None:
"""Refuse a MegaTask redraft unless every root-subtask is still BACKLOG.
In-place mutation (patch / cancel+replace / rewire deps) of a
root-subtask already claimed or dispatched would corrupt live work a
redraft is only safe while the whole batch is still board-held.
"""
for child in children:
if child.status != TaskStatus.BACKLOG:
raise ValidationError(
message=(
f"Root-subtask {child.id} is already "
f"{child.status.value}; a MegaTask redraft is only "
"allowed while every item is still BACKLOG (board-held, "
"unstarted)."
),
field="task_id",
)
def _same_batch_scope(self, sub_draft: dict[str, Any], child: TaskTable) -> bool:
"""True when a revised root-subtask draft still targets ``child``'s scope."""
new_project_id, new_product_id, new_cell_projects = _resolve_draft_scope(
sub_draft
)
return _scope_signature(
new_project_id, new_product_id, new_cell_projects
) == _scope_signature(child.project_id, child.product_id, child.cell_projects)
async def _patch_batch_child(
self, child: TaskTable, sub_draft: dict[str, Any], sequence: int
) -> None:
"""Patch an unchanged-scope root-subtask's content + wave in place."""
from roboco.services.task import get_task_service
normalized = _copy_draft(sub_draft)
self._validate_and_coerce_draft(normalized)
await get_task_service(self._session).update(
UUID(str(child.id)),
title=normalized["title"],
description=compose_description(normalized),
acceptance_criteria=normalized["acceptance_criteria"],
sequence=sequence,
)
async def _rewrite_batch_children(
self,
umbrella: TaskTable,
drafts: list[dict[str, Any]],
children: list[TaskTable],
wave_of: dict[int, int],
agent_id: UUID,
agent_role: str,
) -> dict[int, UUID]:
"""Match each draft to its positional root-subtask; patch, replace, or
create/cancel on a count change. ``children`` is the LIVE set (cancelled
rows excluded), positional in ``created_at`` order the panel
round-trips no per-draft id, the same order the create loop used. A
same-scope match patches in place; a scope change cancels the stale
child and creates its replacement via the create path (reused rather
than reimplementing cell-map patching). Returns
``{draft_idx: child_task_id}``; every returned child's
``dependency_ids`` is cleared (the caller rewires the fresh edges).
"""
from roboco.services.task import get_task_service
task_service = get_task_service(self._session)
umbrella_id = UUID(str(umbrella.id))
batch_id = UUID(str(umbrella.batch_id))
superseded_note = "Superseded by a board-informed MegaTask redraft."
task_of: dict[int, UUID] = {}
for idx, draft in enumerate(drafts):
sub_draft = _batch_subtask_draft(draft)
if idx < len(children) and self._same_batch_scope(sub_draft, children[idx]):
await self._patch_batch_child(children[idx], sub_draft, wave_of[idx])
task_of[idx] = UUID(str(children[idx].id))
continue
if idx < len(children):
await task_service.cancel(
UUID(str(children[idx].id)),
agent_role=agent_role,
cancellation_note=superseded_note,
)
new_child = await self.create_task_from_draft(
sub_draft,
agent_id,
status=TaskStatus.BACKLOG,
placement=BatchPlacement(
parent_task_id=umbrella_id,
batch_id=batch_id,
sequence=wave_of[idx],
team_override=umbrella.team,
),
)
task_of[idx] = UUID(str(new_child.id))
for surplus in children[len(drafts) :]:
await task_service.cancel(
UUID(str(surplus.id)),
agent_role=agent_role,
cancellation_note=superseded_note,
)
for child_id in task_of.values():
await task_service.update(child_id, dependency_ids=[])
return task_of
async def update_live_batch(
self,
task_id: UUID,
title: str,
drafts: list[dict[str, Any]],
agent_id: UUID,
*,
project_ids: list[UUID] | None = None,
route: Literal["board", "main_pm"] = "board",
agent_role: str = "main_pm",
) -> dict[str, Any]:
"""Apply a board-informed re-draft to an existing MegaTask, then route it.
Mirrors :meth:`update_live_draft` for the batch shape: re-sequences the
revised drafts, matches each to its existing LIVE (non-cancelled)
root-subtask positionally (patch in place / replace on a scope change /
create or cancel on a count change see
:meth:`_rewrite_batch_children`), rewires every surviving/new child's
dependency edges to the fresh wave plan, and patches the umbrella's own
title/description/acceptance criteria. Requires every live root-subtask
to still be BACKLOG, and holds the revised drafts to the same
``_validate_batch_scope`` gate as the create path ``project_ids`` is
the scoped repo set (the panel round-trips it); when absent it is
recovered from the live children. Without this gate a redraft could
silently collapse the batch to one project or drift a draft outside the
scoped set.
- ``"main_pm"`` ("Approve & Start") hand the umbrella to the Main PM
via ``approve_and_start`` (which also releases the BACKLOG children).
- ``"board"`` send it back for another review round: clear
``board_review_complete`` so the orchestrator re-dispatches the board.
"""
from roboco.services.task import get_task_service
if not drafts:
raise ValidationError(
message="A MegaTask needs at least one task draft.", field="drafts"
)
task_service = get_task_service(self._session)
umbrella = await self._require_batch_umbrella(task_id)
children = await task_service.get_live_subtasks(task_id)
self._require_children_backlog(children)
scope = project_ids or await task_service.distinct_projects_for_batch(task_id)
self._validate_batch_scope(drafts, scope)
plan = self._sequence_drafts(drafts)
wave_of = {idx: w for w, wave in enumerate(plan.waves) for idx in wave}
task_of = await self._rewrite_batch_children(
umbrella, drafts, children, wave_of, agent_id, agent_role
)
for a, b in plan.edges:
await task_service.add_dependency(task_of[b], task_of[a])
umbrella_draft = _compose_umbrella_draft(title, drafts, plan)
await task_service.update(
task_id,
title=umbrella_draft["title"],
description=compose_description(umbrella_draft),
acceptance_criteria=umbrella_draft["acceptance_criteria"],
)
if route == "main_pm":
await task_service.approve_and_start(
task_id,
notes="Re-drafted with board feedback; approved to build.",
)
else: # re-board: another review round on the revised batch
await task_service.update(task_id, board_review_complete=False)
self.log.info(
"Live intake MegaTask re-draft applied",
task_id=str(task_id),
route=route,
items=len(drafts),
)
return {
"umbrella_task_id": str(task_id),
"root_subtask_ids": [str(task_of[i]) for i in range(len(drafts))],
"waves": plan.waves,
"warnings": plan.warnings,
}
@staticmethod
def _resolve_uuid_field(draft_data: dict[str, Any], key: str) -> UUID | None:
"""Parse ``draft_data[key]`` as a UUID; None if absent, raises if malformed."""
@@ -1149,6 +1332,58 @@ def _draft_cell_map(draft: dict[str, Any]) -> list[tuple[Team, UUID]]:
return out
def _resolve_draft_scope(
draft_data: dict[str, Any],
) -> tuple[UUID | None, UUID | None, list[ProductCellMapping]]:
"""A draft's resolved (project_id, product_id, cell_projects) scope.
The single source both ``create_task_from_draft`` and a MegaTask redraft's
scope-change check use: 2 cells in ``the_work`` multi-cell root-subtask
(no project/product, one ``cell_projects`` row each); exactly 1 cell with no
top-level target that cell's project (a top-level target still wins over
a redundant 1-cell map, #57); otherwise the top-level project_id/product_id.
"""
project_id = PrompterService._resolve_uuid_field(draft_data, "project_id")
product_id = PrompterService._resolve_uuid_field(draft_data, "product_id")
cell_map = _draft_cell_map(draft_data)
cell_projects: list[ProductCellMapping] = []
if len(cell_map) >= _MULTI_CELL_MIN:
cell_projects = [
ProductCellMapping(team=team, project_id=pid) for team, pid in cell_map
]
project_id = None
product_id = None
elif len(cell_map) == 1 and project_id is None and product_id is None:
project_id = cell_map[0][1]
return project_id, product_id, cell_projects
def _scope_signature(
project_id: object | None,
product_id: object | None,
cell_projects: Any,
) -> tuple[UUID | None, UUID | None, frozenset[tuple[str, UUID]]]:
"""Comparable scope key: ``(project_id, product_id, {(team, project_id)})``.
Accepts either ``ProductCellMapping`` (a draft's resolved scope) or
``TaskCellProjectTable`` rows (a persisted child's ``cell_projects``) —
both carry ``.team`` / ``.project_id``, so the same key comparison covers
a MegaTask redraft's "did this root-subtask's scope change?" check.
``project_id`` / ``product_id`` are typed ``object | None`` (mirrors
``roboco.foundation.policy.batch``'s predicates) since callers pass either
a resolved ``uuid.UUID`` or an ORM column value.
"""
cells = frozenset(
(str(getattr(m.team, "value", m.team)), UUID(str(m.project_id)))
for m in cell_projects
)
return (
UUID(str(project_id)) if project_id else None,
UUID(str(product_id)) if product_id else None,
cells,
)
def derive_scale(the_work: list[Any]) -> str:
"""'multi' when more than one cell participates, else 'single'."""
return "multi" if len(_cell_teams(the_work)) > 1 else "single"
@@ -1269,6 +1504,55 @@ def compose_redraft_message(task: TaskTable, entries: list[dict[str, Any]]) -> s
).strip()
def _project_label(child: TaskTable) -> str:
"""Compact project-scope label for a root-subtask redraft snapshot line."""
if child.project is not None:
return str(child.project.name)
if child.cell_projects:
return ", ".join(
f"{_cell_label(cp.team.value)}: {cp.project.name}"
for cp in sorted(child.cell_projects, key=lambda m: m.team.value)
)
return ""
def _render_child_snapshot(idx: int, child: TaskTable) -> str:
"""One numbered root-subtask snapshot: title, project, description, AC."""
criteria = "\n".join(f"- {c}" for c in (child.acceptance_criteria or []))
return (
f"### {idx + 1}. {child.title} ({_project_label(child)})\n\n"
f"{child.description}\n\n"
f"Acceptance criteria:\n{criteria}"
)
def compose_batch_redraft_message(
umbrella: TaskTable, children: list[TaskTable], entries: list[dict[str, Any]]
) -> str:
"""Seed message for a MegaTask re-draft: every root-subtask + board review.
Mirrors :func:`compose_redraft_message` for the batch shape. The board
reviewed the umbrella as one unit, so the revised session must re-propose
the ENTIRE batch (not one item at a time) the closing instruction names
``propose_batch`` explicitly so the revision doesn't silently drop the
sequencing by using the single-task ``propose_draft`` tool instead.
"""
briefing = format_board_briefing(entries)
snapshots = "\n\n".join(
_render_child_snapshot(idx, child) for idx, child in enumerate(children)
)
title = _text(umbrella.title).removeprefix("MegaTask: ")
return (
"You are revising an existing MegaTask (a sequenced batch of "
f"{len(children)} tasks) with board feedback.\n\n"
f"## MegaTask: {title}\n\n{umbrella.description}\n\n"
f"## Current root-subtasks\n\n{snapshots}\n\n{briefing}\n\n"
"Revise the scope per this feedback, then call `propose_batch` ONCE "
"with the FULL revised set of tasks (not `propose_task`/`propose_draft` "
"per item) — the panel needs the whole batch to re-sequence it."
).strip()
def compose_description(draft: dict[str, Any]) -> str:
"""Build the markdown description deterministically from structured fields.
+33
View File
@@ -2253,6 +2253,39 @@ class TaskService(BaseService):
seen.setdefault(UUID(str(mapping.project_id)), None)
return list(seen)
async def get_live_subtasks(self, parent_task_id: UUID) -> list[TaskTable]:
"""Subtasks excluding CANCELLED, in ``created_at`` order.
The view every MegaTask-redraft reader uses (positional draft matching,
the backlog gate, the re-interview snapshot, batch scope recovery) a
child cancelled by a prior redraft round (scope change / count shrink)
must neither refuse the next round nor mismatch its positional pairing.
"""
return [
c
for c in await self.get_subtasks(parent_task_id)
if c.status != TaskStatus.CANCELLED
]
async def distinct_projects_for_batch(self, umbrella_id: UUID) -> list[UUID]:
"""The distinct project ids a MegaTask umbrella's LIVE root-subtasks target.
Each root-subtask carries a single project (``project_id``) or an
ad-hoc per-cell map (``cell_projects``) union across every non-
cancelled child, de-duped, in ``created_at`` order (a repo dropped by a
redraft's cancel must not resurrect in a cold re-interview). Used by the
cold re-interview path to recover a batch's multi-repo intake scope when
no session is parked for it (mirrors ``_distinct_projects_for_task``,
which recovers a single coordination root's own product/cell-map scope).
"""
seen: dict[UUID, None] = {}
for child in await self.get_live_subtasks(umbrella_id):
if child.project_id is not None:
seen.setdefault(UUID(str(child.project_id)), None)
for mapping in sorted(child.cell_projects, key=lambda m: m.team.value):
seen.setdefault(UUID(str(mapping.project_id)), None)
return list(seen)
async def _ensure_coordination_root_branches(
self,
task: TaskTable,
+255 -9
View File
@@ -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)
# --------------------------------------------------------------------------- #
+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 == []
+556 -1
View File
@@ -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."""