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