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). **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 ## Services
Core services in `roboco/services/`: Core services in `roboco/services/`:
+68 -8
View File
@@ -404,6 +404,9 @@ interface PersistedChat {
}; };
editableDraft: EditableDraft; editableDraft: EditableDraft;
redraftTaskId?: string | null; 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. // MegaTask review state, so a reload mid-batch-review restores the batch.
batch?: BatchProposal | null; batch?: BatchProposal | null;
batchWaves?: number[][] | 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 // 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. // then updates that task in place instead of creating a new one.
const redraftTaskIdRef = useRef<string | null>(null); 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 sourceRef = useRef<EventSource | null>(null);
const streamingIdRef = useRef<string | null>(null); const streamingIdRef = useRef<string | null>(null);
// Synchronous re-entry guard for launch — a double-click was creating two tasks. // Synchronous re-entry guard for launch — a double-click was creating two tasks.
@@ -740,6 +747,7 @@ export function usePrompter() {
scope: scopeRef.current, scope: scopeRef.current,
editableDraft, editableDraft,
redraftTaskId: redraftTaskIdRef.current, redraftTaskId: redraftTaskIdRef.current,
batchRedraftTaskId: batchRedraftTaskIdRef.current,
batch, batch,
batchWaves, batchWaves,
savedAt: Date.now(), savedAt: Date.now(),
@@ -779,6 +787,7 @@ export function usePrompter() {
// so land in a stable state rather than "streaming". // so land in a stable state rather than "streaming".
sessionIdRef.current = persisted.sessionId; sessionIdRef.current = persisted.sessionId;
redraftTaskIdRef.current = persisted.redraftTaskId ?? null; redraftTaskIdRef.current = persisted.redraftTaskId ?? null;
batchRedraftTaskIdRef.current = persisted.batchRedraftTaskId ?? null;
setSessionId(persisted.sessionId); setSessionId(persisted.sessionId);
setMessages(persisted.messages); setMessages(persisted.messages);
setEditableDraft(persisted.editableDraft); setEditableDraft(persisted.editableDraft);
@@ -874,8 +883,11 @@ export function usePrompter() {
setState("preparing"); setState("preparing");
try { try {
// Scope the chat to the task's product/project so launch has a target // 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 task = await tasksApi.get(taskId);
const isBatchUmbrella =
!task.project_id && !task.product_id && !!task.batch_id;
if (task.product_id) { if (task.product_id) {
setTargetKind("product"); setTargetKind("product");
setProductId(task.product_id); setProductId(task.product_id);
@@ -883,11 +895,20 @@ export function usePrompter() {
setTargetKind("project"); setTargetKind("project");
setProjectId(task.project_id); setProjectId(task.project_id);
} }
const { session_id } = await prompterLiveApi.reInterview(taskId); const response = await prompterLiveApi.reInterview(taskId);
redraftTaskIdRef.current = taskId; if (isBatchUmbrella) {
sessionIdRef.current = session_id; // The umbrella carries no project scope of its own — seed the
setSessionId(session_id); // MegaTask's multi-project scope from the recovered response so
openStream(session_id); // 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); setIsSending(true);
setActivity("Re-opening intake with the board's feedback…"); setActivity("Re-opening intake with the board's feedback…");
setState("streaming"); setState("streaming");
@@ -1157,17 +1178,46 @@ export function usePrompter() {
launchingRef.current = true; launchingRef.current = true;
setIsLaunching(true); setIsLaunching(true);
setState("launching"); 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 { try {
const result = await prompterLiveApi.confirmBatch(sid, { const result = await prompterLiveApi.confirmBatch(sid, {
title: batch.title.trim() || "MegaTask", title: batch.title.trim() || "MegaTask",
drafts: batch.drafts, drafts: batch.drafts,
project_ids: scopeRef.current.projectIds, project_ids: scopeRef.current.projectIds,
route, 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(); closeStream();
void prompterLiveApi.stop(sid).catch(() => undefined); void prompterLiveApi.stop(sid).catch(() => undefined);
clearPersisted(); clearPersisted();
sessionIdRef.current = null; sessionIdRef.current = null;
batchRedraftTaskIdRef.current = null;
setBatchResult(result); setBatchResult(result);
setCreatedTaskId(result.umbrella_task_id); setCreatedTaskId(result.umbrella_task_id);
setCreatedTaskTitle(batch.title.trim() || "MegaTask"); setCreatedTaskTitle(batch.title.trim() || "MegaTask");
@@ -1192,14 +1242,23 @@ export function usePrompter() {
} }
setState("success"); setState("success");
} catch (err) { } 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"); setState("batch_preview");
} finally { } finally {
setIsLaunching(false); setIsLaunching(false);
launchingRef.current = false; launchingRef.current = false;
} }
}, },
[batch, closeStream], [batch, closeStream, addMessage],
); );
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1213,6 +1272,7 @@ export function usePrompter() {
clearPersisted(); clearPersisted();
sessionIdRef.current = null; sessionIdRef.current = null;
streamingIdRef.current = null; streamingIdRef.current = null;
batchRedraftTaskIdRef.current = null;
setMessages([]); setMessages([]);
setSessionId(null); setSessionId(null);
setActivity(null); setActivity(null);
+7 -1
View File
@@ -32,6 +32,10 @@ export interface StartLivePayload {
export interface StartLiveResponse { export interface StartLiveResponse {
session_id: string; 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. */ /** 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. /** 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> => { reInterview: async (taskId: string): Promise<StartLiveResponse> => {
const { data } = await api.post<StartLiveResponse>( const { data } = await api.post<StartLiveResponse>(
`/prompter/live/re-interview/${taskId}`, `/prompter/live/re-interview/${taskId}`,
+3
View File
@@ -65,6 +65,9 @@ export interface BatchConfirmPayload {
drafts: DraftProposal[]; drafts: DraftProposal[];
project_ids: string[]; project_ids: string[];
route?: "board" | "main_pm"; 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 /** 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, db: DbSession,
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> dict[str, Any]: ) -> 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 Builds the branchless umbrella + one root-subtask per draft, wires the
collision-derived dependency waves, and routes the umbrella per ``route`` collision-derived dependency waves, and routes the umbrella per ``route``
(Board review vs. straight to the Main PM) each root-subtask keeps its own (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 project / branch / PR. Returns the umbrella id, the root-subtask ids, and the
computed waves + warnings for the panel. Always terminal the live session computed waves + warnings for the panel.
is reaped once the drafts are tasks.
``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) service = get_prompter_service(db)
try: try:
result = await service.confirm_live_batch( if body.task_id is not None:
body.title, result = await service.update_live_batch(
body.drafts, body.task_id,
agent.agent_id, body.title,
project_ids=body.project_ids, body.drafts,
route=body.route, agent.agent_id,
session_id=session_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: except ServiceError as e:
raise _translate_service_error(e) from e raise _translate_service_error(e) from e
await db.commit() 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) await get_orchestrator().reap_intake_session(session_id)
return result return result
@@ -305,6 +331,45 @@ async def _intake_scope_for_task(
return None, None 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( @router.post(
"/live/re-interview/{task_id}", "/live/re-interview/{task_id}",
response_model=StartLiveResponse, response_model=StartLiveResponse,
@@ -322,7 +387,12 @@ async def re_interview(
panel opens its stream and, on confirm, passes ``task_id`` so the revised 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 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). (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.journal import get_journal_service
from roboco.services.prompter import compose_redraft_message from roboco.services.prompter import compose_redraft_message
from roboco.services.task import get_task_service from roboco.services.task import get_task_service
@@ -333,14 +403,17 @@ async def re_interview(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" 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) project_slug, product_id = await _intake_scope_for_task(db, task)
if not project_slug and not product_id: if not project_slug and not product_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Task has no project/product scope to re-interview against.", 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) initial_message = compose_redraft_message(task, entries)
session_id = uuid4().hex 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 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): class StartLiveRequest(BaseModel):
"""Open a live intake chat scoped to a project, a product, or a MegaTask. """Open a live intake chat scoped to a project, a product, or a MegaTask.
@@ -38,9 +43,15 @@ class StartLiveRequest(BaseModel):
class StartLiveResponse(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 session_id: str
project_ids: list[UUID] | None = None
class LiveMessageRequest(BaseModel): class LiveMessageRequest(BaseModel):
@@ -99,8 +110,25 @@ class BatchConfirmRequest(BaseModel):
drafts: list[dict[str, Any]] = Field(..., min_length=1) drafts: list[dict[str, Any]] = Field(..., min_length=1)
# The scoped repos the MegaTask spans (the set the intake agent read). Every # 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. # 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" 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): 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. # in-context. Best-effort; the cold "Re-draft" path covers the rest.
await self._inject_board_brief_into_parked_intake(task_id) 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: 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. """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) session = get_live_registry().find_by_task(task_id)
if session is None: if session is None:
return return
from uuid import UUID
from roboco.db.base import get_db_context 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: try:
async with get_db_context() as db: async with get_db_context() as db:
task = await get_task_service(db).get(UUID(task_id)) message = await self._compose_parked_intake_redraft(db, task_id)
if task is None: if message is None:
return return
entries = await get_journal_service(db).board_review_brief(
UUID(task_id)
)
message = compose_redraft_message(task, entries)
delivered = await get_live_registry().deliver(session.session_id, message) delivered = await get_live_registry().deliver(session.session_id, message)
logger.info( logger.info(
"Injected board feedback into parked intake", "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. # the task always carries a freshly-composed, consistent description.
draft_data["description"] = compose_description(draft_data) draft_data["description"] = compose_description(draft_data)
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id") resolved_project_id, resolved_product_id, cell_projects = _resolve_draft_scope(
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id") draft_data
# 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]
self._validate_draft_target( self._validate_draft_target(
resolved_project_id, resolved_project_id,
resolved_product_id, resolved_product_id,
@@ -912,6 +892,209 @@ class PrompterService:
) )
return task_id 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 @staticmethod
def _resolve_uuid_field(draft_data: dict[str, Any], key: str) -> UUID | None: 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.""" """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 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: def derive_scale(the_work: list[Any]) -> str:
"""'multi' when more than one cell participates, else 'single'.""" """'multi' when more than one cell participates, else 'single'."""
return "multi" if len(_cell_teams(the_work)) > 1 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() ).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: def compose_description(draft: dict[str, Any]) -> str:
"""Build the markdown description deterministically from structured fields. """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) seen.setdefault(UUID(str(mapping.project_id)), None)
return list(seen) 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( async def _ensure_coordination_root_branches(
self, self,
task: TaskTable, 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.deps import get_agent_context
from roboco.api.routes.prompter_live import router from roboco.api.routes.prompter_live import router
from roboco.db.base import get_db 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 import prompter_live
from roboco.services.base import ValidationError from roboco.services.base import ValidationError
from roboco.services.permissions import AgentContext 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: if TYPE_CHECKING:
from collections.abc import AsyncIterator 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") 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 = FastAPI()
app.include_router(router, prefix="/api/prompter") app.include_router(router, prefix="/api/prompter")
app.dependency_overrides[get_db] = _fake_db app.dependency_overrides[get_db] = _fake_db
app.dependency_overrides[get_agent_context] = lambda: ceo app.dependency_overrides[get_agent_context] = lambda: ceo
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: 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 @pytest.mark.asyncio
@@ -384,16 +398,23 @@ def _batch_body() -> dict[str, Any]:
} }
@pytest.mark.asyncio def _batch_result(*, n: int = 2) -> dict[str, Any]:
async def test_confirm_batch_creates_and_reaps(confirm_client: dict) -> None: return {
client, orch = confirm_client["client"], confirm_client["orch"]
result = {
"umbrella_task_id": str(uuid4()), "umbrella_task_id": str(uuid4()),
"root_subtask_ids": [str(uuid4()), str(uuid4())], "root_subtask_ids": [str(uuid4()) for _ in range(n)],
"waves": [[0], [1]], "waves": [[i] for i in range(n)],
"warnings": [], "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: class _FakeService:
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any: async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
return result 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.status_code == HTTPStatus.CREATED
assert resp.json() == result 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 @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 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. # 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.base import NotFoundError
from roboco.services.prompter import ( from roboco.services.prompter import (
PrompterService, PrompterService,
compose_batch_redraft_message,
compose_redraft_message, compose_redraft_message,
format_board_briefing, format_board_briefing,
) )
@@ -63,6 +64,60 @@ def test_compose_redraft_message_includes_draft_and_brief() -> None:
assert "z" in msg 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) # update_live_draft (DB)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+135
View File
@@ -13,6 +13,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from uuid import uuid4 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 "i_am_idle()" in prompt
assert "Product Owner" in prompt and "Head of Marketing" 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() 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.seeds.initial_data import AGENT_UUIDS
from roboco.services import prompter as prompter_module 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 ( from roboco.services.prompter import (
_HISTORY_DIGEST_PER_PROJECT_LIMIT, _HISTORY_DIGEST_PER_PROJECT_LIMIT,
_HISTORY_TITLE_EXCERPT_CAP, _HISTORY_TITLE_EXCERPT_CAP,
@@ -55,6 +55,7 @@ from roboco.services.prompter import (
history_digest_layer, history_digest_layer,
parse_readiness, parse_readiness,
) )
from roboco.services.task import get_task_service
# ============================================================================= # =============================================================================
# Pure function tests (no DB) # 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: def test_preview_batch_computes_waves_without_creating() -> None:
"""preview_batch is pure: it returns the same waves confirm would wire, with """preview_batch is pure: it returns the same waves confirm would wire, with
no DB session and no task creation.""" no DB session and no task creation."""