Feat: board redraft loop (#139)

* feat(board): expose board review brief + guard approve-and-start

Slice 1 of the board-informed intake re-draft loop (backend foundation):

- JournalService.board_review_brief(task_id): the PO + Head of Marketing
  DECISION_LOG entries for a task, oldest-first, each tagged with author —
  the board's review as structured data.
- GET /api/tasks/{task_id}/board-review (PM-or-above) backing the CEO's
  approval/redraft surface, so the real board analysis is readable instead
  of a placeholder; BoardReviewEntry response schema.
- Guard: approve_and_start now refuses a board task whose review is not
  complete (service invariant + precise BOARD_REVIEW_INCOMPLETE at the route).
  Previously only the UI hid the button; the backend let an early/rogue call
  hand the task to Main PM mid-review.

Tests: brief filtering/ordering + endpoint (200/404) + the two guard paths.

* feat(panel): show real board review at the approve gate + live refresh

Slice 1 frontend of the board-informed intake re-draft loop:

- tasksApi.getBoardReview + useBoardReview hook consume GET
  /tasks/{id}/board-review.
- The Approve & Start dialog now renders the actual Product Owner + Head of
  Marketing notes (markdown) instead of a static placeholder, so the CEO reads
  the board's analysis before approving.
- C2: useTask polls (4s) while a task is still on the board with an
  incomplete review, so the Approve & Start button appears as soon as the
  board finishes — there is no per-task websocket. Polling stops once
  board_review_complete flips.

* feat(intake): board-informed re-draft loop (backend, cold path)

Slice 2 of the re-draft loop:

- update_live_draft: apply a board-informed re-draft to the EXISTING task in
  place (title/description/acceptance_criteria) — never a duplicate — then route
  it: 'main_pm' hands it to the Main PM via approve_and_start; 'board' clears
  board_review_complete for another review round.
- confirm route branches on task_id → update_live_draft vs confirm_live_draft;
  LiveConfirmRequest.task_id added (scope taken from the task, not required).
- POST /live/re-interview/{task_id} (PM-or-above): spawns a fresh intake session
  seeded with the current draft + the board brief (compose_redraft_message),
  scoped to the task's product/project. The cold path + Slice-3 fallback.
- format_board_briefing / compose_redraft_message helpers.

Tests: pure helpers + update_live_draft (main_pm hand-off, re-board reset,
missing-task).

* feat(panel): board-informed re-draft entry + prompter re-draft guidance

Slice 2 panel of the re-draft loop:

- 'Re-draft with board feedback' button on a board-reviewed task detail →
  /prompter?redraft=<taskId>.
- usePrompter.startRedraft(taskId): calls POST /prompter/live/re-interview/{id},
  scopes the chat to the task, and streams the re-draft; redraftTaskId is
  threaded (persisted across reload) so confirm carries task_id and updates the
  existing task in place rather than creating a duplicate.
- prompterLiveApi.reInterview; ConfirmPayload.task_id.
- Prompter role prompt: a 'Re-drafting after board review' section so the agent
  revises the included draft from the board brief instead of starting over.

Panel verified by CI (no local node_modules).

* feat(intake): keep-alive re-draft — park the intake agent during board review

Slice 3 of the re-draft loop (in-context fidelity; cold path is the fallback):

- Registry: LiveIntakeSession.task_id + park(session_id, task_id) (keep alive
  instead of reaping) + find_by_task() for board-completion injection.
- Confirm: the board route (first pass) PARKS the intake agent instead of
  reaping, so it keeps the whole interview in context.
- Orchestrator: on board-review completion, inject the synthesized board brief
  into the parked session (_inject_board_brief_into_parked_intake) so the
  resident prompter re-drafts in-context. No-op when nothing is parked (the
  container died / a new intake replaced it) — the cold /re-interview path
  covers that. No reaper change needed (an idle parked session spends no tokens
  and the budget sweep is the only agent-stopping sweep).
- Panel: confirm(board) keeps the chat alive (parked, redraftTaskId set) with a
  notice; the injected revised draft arrives over the existing stream to approve.

Tests: registry park/find_by_task/closed-ignored. Container delivery + the full
panel parked flow need live (container-runtime) verification.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-14 00:40:18 +02:00
committed by GitHub
co-authored by Renn F
parent 73b7c16211
commit a6b67a6a58
21 changed files with 1031 additions and 24 deletions
+14
View File
@@ -70,6 +70,20 @@ When — and only when — you can write a complete spec:
A draft card appears for the human with three choices: **Keep chatting**, **Board review & Start**, or **Approve & Start**. **Choosing is the human's action, not yours** — you cannot create, start, or route the task. If they pick **Board review & Start**, it becomes a pending task owned by the Board (Product Owner + Head of Marketing) to review first; if they pick **Approve & Start**, it becomes a pending task that goes straight to the Main PM to delegate to the cells. Either way, your job ends the moment you call `propose_draft`. Do not say you'll "kick it off", "send it to the PM chain", or route it anywhere — you have no such ability, and which path it takes is the human's choice on the card.
## Re-drafting after board review
Sometimes your opening message is not a fresh request but a **revision brief**: it
contains the current task draft plus the Product Owner / Head of Marketing review
("You are revising an existing task draft with board feedback"). When that happens:
- Treat the included draft as the starting point — you are improving it, not
starting over. Keep what's good; change what the board flagged.
- Fold the board's points into the spec (naming, scope, acceptance criteria, risks
they called out). Where two reviewers conflict, reconcile sensibly and note it.
- Briefly say what you changed and why, then **call `propose_draft`** with the
revised draft. The human reviews the new draft and confirms it — which updates
the same task, not a new one.
## Workflow
1. Read the scoped repo(s) to ground yourself in the real surface.
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { Loader2, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { usePrompter } from "@/hooks/use-prompter";
@@ -34,8 +35,21 @@ export default function PrompterPage() {
launchTask,
startAnother,
isLaunching,
startRedraft,
} = usePrompter();
// Entry from a task's "Re-draft with board feedback" button: ?redraft=<taskId>
// re-opens intake seeded with the board's review of that task. Fire once.
const redraftTriggered = useRef(false);
useEffect(() => {
if (redraftTriggered.current) return;
const taskId = new URLSearchParams(window.location.search).get("redraft");
if (taskId) {
redraftTriggered.current = true;
void startRedraft(taskId);
}
}, [startRedraft]);
const showForm = state === "form" || state === "preparing";
const isComposerDisabled =
state === "launching" || state === "success" || isSending;
@@ -20,6 +20,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertTriangle, ArrowLeft, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
interface TaskDetailPageProps {
@@ -28,6 +29,7 @@ interface TaskDetailPageProps {
export default function TaskDetailPage({ params }: TaskDetailPageProps) {
const { taskId } = use(params);
const router = useRouter();
const { data: task, isLoading, error, refetch } = useTask(taskId);
const { data: project } = useProject(task?.project_id ?? "");
const lifecycle = useTaskLifecycle();
@@ -377,7 +379,15 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
{task.status === TaskStatus.PENDING &&
task.board_review_complete === true &&
task.team !== Team.MAIN_PM && (
<div className="flex justify-end">
<div className="flex justify-end gap-2">
{task.team === Team.BOARD && (
<Button
variant="outline"
onClick={() => router.push(`/prompter?redraft=${task.id}`)}
>
Re-draft with board feedback
</Button>
)}
<ApproveAndStartButton task={task} />
</div>
)}
@@ -3,6 +3,8 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { tasksApi } from "@/lib/api";
import { useBoardReview } from "@/hooks/use-tasks";
import { Markdown } from "@/components/ui/markdown";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -22,10 +24,22 @@ interface ApproveAndStartButtonProps {
task: Task;
}
function roleLabel(role: string): string {
if (role === "product_owner") return "Product Owner";
if (role === "head_marketing") return "Head of Marketing";
return role;
}
export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [notes, setNotes] = useState("");
// Fetch the board's actual review only while the dialog is open, so the CEO
// reads the PO + Head of Marketing analysis before approving.
const { data: boardReview = [], isLoading: boardLoading } = useBoardReview(
task.id,
open,
);
const approveAndStartMutation = useMutation({
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
@@ -78,11 +92,39 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Board review (Product Owner &amp; Head of Marketing)</Label>
{boardLoading ? (
<p className="text-xs text-muted-foreground">
Loading board review
</p>
) : boardReview.length === 0 ? (
<p className="text-xs text-muted-foreground">
No board review recorded for this task yet.
</p>
) : (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-3">
{boardReview.map((entry) => (
<div
key={entry.timestamp ?? `${entry.author}-${entry.title}`}
className="space-y-1"
>
<span className="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{roleLabel(entry.author_role)}
</span>
<div className="text-sm font-medium">{entry.title}</div>
<Markdown compact>{entry.content}</Markdown>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="approve-and-start-notes">Approval notes (required)</Label>
<Textarea
id="approve-and-start-notes"
placeholder="Board review complete; requirements are clear. Build it..."
placeholder="Board review read; requirements are clear. Build it..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
+65 -1
View File
@@ -14,6 +14,7 @@ import {
type ConfirmPayload,
} from "@/lib/api/prompter";
import { getErrorMessage } from "@/lib/api/client";
import { tasksApi } from "@/lib/api/tasks";
import { Team } from "@/types";
import type { TaskType, TaskNature, Complexity } from "@/types";
@@ -162,6 +163,7 @@ interface PersistedChat {
state: PrompterState;
scope: { targetKind: TargetKind; projectId: string; productId: string };
editableDraft: EditableDraft;
redraftTaskId?: string | null;
savedAt: number;
}
@@ -225,6 +227,9 @@ export function usePrompter() {
// Live-session plumbing held in refs so SSE callbacks never see stale state.
const sessionIdRef = useRef<string | null>(null);
// 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);
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.
@@ -390,6 +395,7 @@ export function usePrompter() {
state,
scope: scopeRef.current,
editableDraft,
redraftTaskId: redraftTaskIdRef.current,
savedAt: Date.now(),
});
}
@@ -415,6 +421,7 @@ export function usePrompter() {
// events. Any tokens from a turn that was mid-flight at reload are gone,
// so land in a stable state rather than "streaming".
sessionIdRef.current = persisted.sessionId;
redraftTaskIdRef.current = persisted.redraftTaskId ?? null;
setSessionId(persisted.sessionId);
setMessages(persisted.messages);
setEditableDraft(persisted.editableDraft);
@@ -482,6 +489,41 @@ export function usePrompter() {
openStream,
]);
// -----------------------------------------------------------------------
// Re-draft an existing board-reviewed task with the board's feedback
// -----------------------------------------------------------------------
const startRedraft = useCallback(
async (taskId: string) => {
if (state === "preparing" || sessionIdRef.current) return;
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.
const task = await tasksApi.get(taskId);
if (task.product_id) {
setTargetKind("product");
setProductId(task.product_id);
} else if (task.project_id) {
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);
setIsSending(true);
setActivity("Re-opening intake with the board's feedback…");
setState("streaming");
} catch (err) {
addMessage({ role: "error", content: getErrorMessage(err) });
setState("form");
}
},
[state, addMessage, openStream],
);
// -----------------------------------------------------------------------
// Send a chat message
// -----------------------------------------------------------------------
@@ -580,6 +622,11 @@ export function usePrompter() {
editableDraft.targetKind === "product"
? { product_id: editableDraft.productId, draft, route }
: { project_id: editableDraft.projectId, draft, route };
// Board-informed re-draft: confirm updates the existing task in place.
const redraftId = redraftTaskIdRef.current;
if (redraftId) {
payload.task_id = redraftId;
}
const effectiveTeam =
editableDraft.targetKind === "product"
@@ -588,11 +635,27 @@ export function usePrompter() {
try {
const { task_id } = await prompterLiveApi.confirm(sid, payload);
// Board route, first pass: the backend parked the intake agent so the
// board's feedback can be injected for an in-place re-draft. Keep the chat
// alive (don't reap) — the revised draft will arrive here to approve.
if (route === "board" && !redraftId) {
redraftTaskIdRef.current = task_id;
addMessage({
role: "assistant",
content:
"Sent to the board — the Product Owner and Head of Marketing are " +
"reviewing this. Their feedback will arrive here as a revised draft " +
"you can approve. You can leave and come back; this chat stays open.",
});
setState("chatting");
return; // `finally` resets the launching guard
}
// The draft became a task — reap the agent and close the stream.
closeStream();
void prompterLiveApi.stop(sid).catch(() => undefined);
clearPersisted();
sessionIdRef.current = null;
redraftTaskIdRef.current = null;
setCreatedTaskId(task_id);
setCreatedTaskTitle(draft.title);
setCreatedTaskTeam(effectiveTeam);
@@ -605,7 +668,7 @@ export function usePrompter() {
setIsLaunching(false);
launchingRef.current = false;
}
}, [editableDraft, isValidForLaunch, closeStream]);
}, [editableDraft, isValidForLaunch, closeStream, addMessage]);
// -----------------------------------------------------------------------
// Reset to start another conversation
@@ -655,6 +718,7 @@ export function usePrompter() {
setInitialMessage,
isFormValid,
start,
startRedraft,
// Chat + confirm
send,
+29 -8
View File
@@ -1,13 +1,14 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { tasksApi, type TaskFilters } from "@/lib/api/tasks";
import type {
Task,
TaskCreate,
ProgressRequest,
CheckpointRequest,
CommitRequest,
SoftBlockRequest,
EscalateRequest,
import {
Team,
type Task,
type TaskCreate,
type ProgressRequest,
type CheckpointRequest,
type CommitRequest,
type SoftBlockRequest,
type EscalateRequest,
} from "@/types";
// Type for task updates - allows any Task field to be updated
@@ -21,6 +22,7 @@ export const taskKeys = {
details: () => [...taskKeys.all, "detail"] as const,
detail: (id: string) => [...taskKeys.details(), id] as const,
subtasks: (parentId: string) => [...taskKeys.all, "subtasks", parentId] as const,
boardReview: (id: string) => [...taskKeys.all, "board-review", id] as const,
stats: () => [...taskKeys.all, "stats"] as const,
statsByTeam: () => [...taskKeys.all, "stats-by-team"] as const,
};
@@ -39,6 +41,25 @@ export function useTask(taskId: string) {
queryKey: taskKeys.detail(taskId),
queryFn: () => tasksApi.get(taskId),
enabled: !!taskId,
// No per-task websocket exists, so while a board task is mid-review we poll
// the detail so the "Approve & Start" button appears as soon as the board
// finishes. Polling stops the moment board_review_complete flips true.
refetchInterval: (query) =>
query.state.data?.team === Team.BOARD &&
!query.state.data?.board_review_complete
? 4000
: false,
});
}
// The board's review (PO + Head of Marketing) for a task. Enabled lazily so
// it's only fetched where it's shown (e.g. the approve/redraft surface).
export function useBoardReview(taskId: string, enabled = true) {
return useQuery({
queryKey: taskKeys.boardReview(taskId),
queryFn: () => tasksApi.getBoardReview(taskId),
enabled: !!taskId && enabled,
staleTime: 30000,
});
}
+9
View File
@@ -67,6 +67,15 @@ export const prompterLiveApi = {
return data;
},
/** 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. */
reInterview: async (taskId: string): Promise<StartLiveResponse> => {
const { data } = await api.post<StartLiveResponse>(
`/prompter/live/re-interview/${taskId}`
);
return data;
},
/** SSE URL the panel opens to watch the agent. EventSource sends no headers
* (the route is keyed by the opaque session id on the trusted network). */
streamUrl: (sessionId: string): string =>
+3
View File
@@ -44,4 +44,7 @@ export interface ConfirmPayload {
product_id?: string;
draft?: DraftProposal;
route?: "board" | "main_pm";
// Set on a board-informed re-draft: the confirm updates this existing task in
// place instead of creating a new one (scope is taken from the task).
task_id?: string;
}
+20
View File
@@ -21,6 +21,16 @@ export interface TaskFilters {
offset?: number;
}
// One board reviewer's decision-log entry for a task (PO or Head of Marketing).
// Matches the backend BoardReviewEntry schema.
export interface BoardReviewEntry {
author: string;
author_role: string;
title: string;
content: string;
timestamp: string | null;
}
export const tasksApi = {
// List tasks with optional filters
list: async (filters?: TaskFilters): Promise<Task[]> => {
@@ -58,6 +68,16 @@ export const tasksApi = {
return data;
},
// The board's review (PO + Head of Marketing decision logs) for a task,
// oldest-first. Empty until the board has reviewed.
getBoardReview: async (taskId: string): Promise<BoardReviewEntry[]> => {
if (isMockMode()) return [];
const { data } = await api.get<BoardReviewEntry[]>(
"/tasks/" + taskId + "/board-review",
);
return data;
},
// Create task
create: async (task: TaskCreate): Promise<Task> => {
if (isMockMode()) {
+104 -8
View File
@@ -24,7 +24,12 @@ from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from sse_starlette import EventSourceResponse
from roboco.api.deps import CurrentAgentContext, DbSession, get_orchestrator
from roboco.api.deps import (
CurrentAgentContext,
DbSession,
get_orchestrator,
require_pm_or_above,
)
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter_live import get_live_registry
@@ -195,9 +200,15 @@ class LiveConfirmRequest(BaseModel):
product_id: UUID | None = None
draft: dict[str, Any]
route: Literal["board", "main_pm"] = "board"
# Set on a board-informed re-draft: confirm updates this existing task in
# place instead of creating a new one. When present, project/product scope
# is taken from the task, so neither is required here.
task_id: UUID | None = None
@model_validator(mode="after")
def _exactly_one_target(self) -> LiveConfirmRequest:
if self.task_id is not None:
return self
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
@@ -219,22 +230,107 @@ async def confirm_live(
"""
service = get_prompter_service(db)
try:
task_id = await service.confirm_live_draft(
body.draft,
agent.agent_id,
project_id=body.project_id,
product_id=body.product_id,
route=body.route,
)
if body.task_id is not None:
# Board-informed re-draft: update the existing task in place.
task_id = await service.update_live_draft(
body.task_id, body.draft, route=body.route
)
else:
task_id = await service.confirm_live_draft(
body.draft,
agent.agent_id,
project_id=body.project_id,
product_id=body.product_id,
route=body.route,
)
except ServiceError as e:
raise _translate_service_error(e) from e
await db.commit()
# Board route (first confirm, not a re-draft): keep the intake agent alive
# and PARK it against this task, so when the board finishes its review the
# orchestrator can inject that feedback in-context for an in-place re-draft
# (the agent still holds the whole interview). Every other path is terminal
# → reap. If parking fails (session already gone), fall through to reap so
# nothing leaks.
if (
body.task_id is None
and body.route == "board"
and get_live_registry().park(session_id, str(task_id))
):
return {"task_id": str(task_id)}
# The draft is now a task — reap the agent + close the relay stream.
await get_orchestrator().reap_intake_session(session_id)
return {"task_id": str(task_id)}
async def _intake_scope_for_task(
db: DbSession, task: Any
) -> tuple[str | None, str | None]:
"""Return (project_slug, product_id) intake scope for a task — exactly one."""
if task.product_id is not None:
return None, str(task.product_id)
if task.project_id is not None:
from roboco.services.project import get_project_service
proj = await get_project_service(db).get(UUID(str(task.project_id)))
return (proj.slug if proj else None), None
return None, None
@router.post(
"/live/re-interview/{task_id}",
response_model=StartLiveResponse,
status_code=status.HTTP_201_CREATED,
)
async def re_interview(
task_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> StartLiveResponse:
"""Re-open intake to re-draft a board-reviewed task with the board's feedback.
Spawns a fresh intake session seeded with the current draft + the Product
Owner / Head of Marketing review, scoped to the task's product/project. The
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).
"""
from roboco.services.journal import get_journal_service
from roboco.services.prompter import compose_redraft_message
from roboco.services.task import get_task_service
require_pm_or_above(agent.role, "re-interview a task")
task = await get_task_service(db).get(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found"
)
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
try:
await get_orchestrator().start_intake_session(
session_id,
project_slug=project_slug,
product_id=product_id,
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)
@router.post("/live/{session_id}/events")
async def relay_event(session_id: str, event: AgentEvent) -> dict[str, bool]:
"""Relay one agent event from the container onto the session's stream."""
+38 -1
View File
@@ -4,7 +4,7 @@ Task API Routes
Full CRUD operations and lifecycle management for tasks.
"""
from typing import Annotated, cast
from typing import Annotated, Any, cast
from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status
@@ -14,12 +14,14 @@ from roboco.api.deps import (
DbSession,
PermissionServiceDep,
get_permission_service,
require_pm_or_above,
)
from roboco.api.schemas.sessions import (
SessionTaskLinkResponse,
TaskSessionsResponse,
)
from roboco.api.schemas.tasks import (
BoardReviewEntry,
CancelTaskRequest,
CheckpointRequest,
ClaimRequest,
@@ -52,6 +54,7 @@ from roboco.services.base import (
UnauthorizedError,
ValidationError,
)
from roboco.services.journal import get_journal_service
from roboco.services.messaging import get_messaging_service
from roboco.services.notification_delivery import (
EscalationError,
@@ -612,6 +615,27 @@ async def get_descendants(
return task_list_to_response(tasks)
@router.get("/{task_id}/board-review", response_model=list[BoardReviewEntry])
async def get_board_review(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> list[dict[str, Any]]:
"""Return the board's review of a task — the Product Owner + Head of
Marketing decision-log entries so the CEO can read the actual analysis at
the approval/redraft gate instead of a placeholder. PM-or-above only.
Empty list when the board has not reviewed yet.
"""
require_pm_or_above(agent.role, "view the board review")
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return await get_journal_service(db).board_review_brief(task_id)
# =============================================================================
# LIFECYCLE ENDPOINTS
# =============================================================================
@@ -1392,6 +1416,19 @@ async def approve_and_start_task(
),
)
# A board task can't be started until the board has finished reviewing.
# The service enforces this too (defense in depth); the route surfaces a
# precise message rather than the generic "not startable".
if task.team == Team.BOARD and not task.board_review_complete:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"BOARD_REVIEW_INCOMPLETE: the Product Owner and Head of "
"Marketing must finish reviewing before this task can be "
"approved and started."
),
)
task = await service.approve_and_start(task_id, data.notes)
if not task:
raise HTTPException(
+14
View File
@@ -23,6 +23,20 @@ from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid
# =============================================================================
class BoardReviewEntry(BaseModel):
"""One board reviewer's decision-log entry for a task (PO or Head of Marketing).
Surfaced at the CEO's approval/redraft gate so the actual board analysis is
readable instead of a placeholder.
"""
author: str
author_role: str
title: str
content: str
timestamp: str | None = None
class ProgressUpdateResponse(BaseModel):
"""A progress update on a task."""
+45
View File
@@ -5872,6 +5872,51 @@ Start now: evidence(task_id="{task_id}")
"Board review complete — CEO Approve & Start unlocked",
task_id=task_id,
)
# Keep-alive re-draft: if an intake chat is parked awaiting this review,
# inject the board's feedback so the still-resident prompter re-drafts
# in-context. Best-effort; the cold "Re-draft" path covers the rest.
await self._inject_board_brief_into_parked_intake(task_id)
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.
No-op when no session is parked for this task (it was reaped, the
container died, or the draft never used the board route) the CEO then
re-drafts via the cold ``/re-interview`` path instead. Never raises.
"""
from roboco.services.prompter_live import get_live_registry
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)
delivered = await get_live_registry().deliver(session.session_id, message)
logger.info(
"Injected board feedback into parked intake",
task_id=task_id,
delivered=delivered,
)
except Exception as exc:
logger.warning(
"Failed to inject board feedback into parked intake",
task_id=task_id,
error=str(exc),
)
def _pm_spawn_prompt(
self, routing: str, agent_id: str, task: dict[str, Any]
+41 -2
View File
@@ -13,11 +13,11 @@ from uuid import UUID
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import JournalEntryTable, JournalTable
from roboco.db.tables import AgentTable, JournalEntryTable, JournalTable
from roboco.foundation.policy.journaling import (
SCOPE_TO_TYPE as _FOUNDATION_SCOPE_TO_TYPE,
)
from roboco.models.base import JournalEntryType
from roboco.models.base import AgentRole, JournalEntryType
from roboco.models.journal import (
DecisionLogParams,
GeneralEntryParams,
@@ -404,6 +404,45 @@ class JournalService(BaseService):
for row in rows
]
async def board_review_brief(self, task_id: UUID) -> list[dict[str, Any]]:
"""The board's review of a task: Product Owner + Head of Marketing
``DECISION_LOG`` entries tied to ``task_id``, oldest first.
This is the board's handoff in structured form. It backs the CEO's
approval/redraft surface so the actual PO + HoM analysis is visible
(instead of the static placeholder shown at the gate today), and feeds
the intake re-draft loop. Each row carries its author (slug + role).
Returns an empty list when the board has not reviewed yet.
"""
board_roles = (AgentRole.PRODUCT_OWNER, AgentRole.HEAD_MARKETING)
query = (
select(
JournalEntryTable.title,
JournalEntryTable.content,
JournalEntryTable.timestamp,
AgentTable.slug,
AgentTable.role,
)
.join(JournalTable, JournalEntryTable.journal_id == JournalTable.id)
.join(AgentTable, JournalTable.agent_id == AgentTable.id)
.where(JournalEntryTable.task_id == task_id)
.where(JournalEntryTable.type == JournalEntryType.DECISION_LOG)
.where(AgentTable.role.in_(board_roles))
.order_by(JournalEntryTable.timestamp.asc())
.limit(20)
)
result = await self.session.execute(query)
return [
{
"author": row.slug,
"author_role": str(row.role),
"title": row.title,
"content": row.content,
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
}
for row in result.all()
]
async def delete_entry(self, entry_id: UUID) -> bool:
"""Delete a journal entry."""
result = await self.session.execute(
+93 -1
View File
@@ -30,7 +30,7 @@ from roboco.models.base import (
Team,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.base import ServiceError, ValidationError
from roboco.services.base import NotFoundError, ServiceError, ValidationError
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -192,6 +192,9 @@ class PrompterService:
- ``"main_pm"`` ("Approve & Start") task at PENDING assigned to the Main
PM, who delegates to the cells directly (Board review skipped).
For a board-informed *re-draft* of an existing task the route calls
:meth:`update_live_draft` instead (updates in place, no new task).
Enum fields the dialog doesn't surface default to sane values so a
confirm never fails on a missing ``nature``.
"""
@@ -221,6 +224,54 @@ class PrompterService:
)
return UUID(str(task.id))
async def update_live_draft(
self,
task_id: UUID,
draft: dict[str, Any],
*,
route: Literal["board", "main_pm"] = "main_pm",
) -> UUID:
"""Apply a board-informed re-draft to an existing task, then route it.
The board reviewed the task; the prompter folded that feedback into a
revised draft. This updates the *same* coordination task in place
(title / description / acceptance criteria) never a new task, which
would duplicate the one the board already reviewed then routes per the
button the CEO pressed on the re-draft:
- ``"main_pm"`` ("Approve & Start") hand the revised task to the Main
PM via ``approve_and_start`` (board review is already complete).
- ``"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
draft_data: dict[str, Any] = dict(draft)
draft_data["description"] = compose_description(draft_data)
task_service = get_task_service(self._session)
task = await task_service.update(
task_id,
title=draft_data.get("title"),
description=draft_data["description"],
acceptance_criteria=draft_data.get("acceptance_criteria"),
)
if task is None:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
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 draft
await task_service.update(task_id, board_review_complete=False)
self.log.info(
"Live intake re-draft applied",
task_id=str(task_id),
route=route,
)
return task_id
@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."""
@@ -427,6 +478,47 @@ def _section(sections: list[str], heading: str, body: str) -> None:
sections.append(f"## {heading}\n\n{body}")
def format_board_briefing(entries: list[dict[str, Any]]) -> str:
"""Render board review entries into a markdown briefing for the prompter.
Used to seed a re-draft intake session with the Product Owner + Head of
Marketing analysis so the agent revises the draft against real feedback.
"""
if not entries:
return ""
role_label = {
"product_owner": "Product Owner",
"head_marketing": "Head of Marketing",
}
blocks: list[str] = [
"The board reviewed your draft. Revise it to incorporate their feedback, "
"then propose the updated draft. Their reviews:",
]
for e in entries:
who = role_label.get(str(e.get("author_role")), str(e.get("author") or "Board"))
title = str(e.get("title") or "").strip()
content = str(e.get("content") or "").strip()
header = f"### {who}" + (f"{title}" if title else "")
blocks.append(f"{header}\n\n{content}")
return "\n\n".join(blocks)
def compose_redraft_message(task: TaskTable, entries: list[dict[str, Any]]) -> str:
"""Seed message for a re-draft intake session: the current draft + board review.
Gives the prompter the existing task draft to revise plus the Product Owner /
Head of Marketing feedback to fold in, so the fresh session re-drafts the same
task rather than starting from scratch.
"""
criteria = "\n".join(f"- {c}" for c in (task.acceptance_criteria or []))
briefing = format_board_briefing(entries)
return (
"You are revising an existing task draft with board feedback.\n\n"
f"## Current draft: {task.title}\n\n{task.description}\n\n"
f"### Acceptance criteria\n{criteria}\n\n{briefing}"
).strip()
def compose_description(draft: dict[str, Any]) -> str:
"""Build the markdown description deterministically from structured fields.
+30
View File
@@ -43,6 +43,10 @@ class LiveIntakeSession:
agent_id: str # container agent id, e.g. "intake-3f9c1a2b"
queue: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
closed: bool = False
# Set when the chat is *parked* awaiting board review of this task: the
# session stays alive (not reaped) so the board's feedback can be injected
# in-context for an in-place re-draft. ``None`` for a normal live chat.
task_id: str | None = None
class PrompterLiveRegistry:
@@ -97,6 +101,32 @@ class PrompterLiveRegistry:
session.queue.put_nowait(_CLOSE)
self.log.info("Live intake session closed", session_id=session_id)
def park(self, session_id: str, task_id: str) -> bool:
"""Mark a session as parked awaiting board review of ``task_id``.
Keeps the session alive (the opposite of ``close``): the intake agent
stays resident with the full interview in context, so the board's
feedback can be injected for an in-place re-draft. False if no such
live session (it was already reaped / never opened).
"""
session = self._sessions.get(session_id)
if session is None or session.closed:
return False
session.task_id = task_id
self.log.info(
"Live intake session parked for board review",
session_id=session_id,
task_id=task_id,
)
return True
def find_by_task(self, task_id: str) -> LiveIntakeSession | None:
"""Return the live (un-closed) session parked for ``task_id``, if any."""
for session in self._sessions.values():
if session.task_id == task_id and not session.closed:
return session
return None
# -- agent -> panel ----------------------------------------------------
def push(self, session_id: str, event: dict[str, Any]) -> bool:
+12
View File
@@ -3889,6 +3889,18 @@ class TaskService(BaseService):
current_status=task.status.value,
)
return None
# A board task may not be handed to Main PM until the Product Owner and
# Head of Marketing have finished reviewing. Today only the UI hides the
# button; enforce the invariant server-side so an early/rogue call can't
# start the work before the board's input exists. Non-board tasks
# (team already MAIN_PM) are unaffected — only a task still on the board
# carries this precondition.
if task.team == Team.BOARD and not task.board_review_complete:
self.log.warning(
"Cannot approve_and_start - board review not complete",
task_id=str(task_id),
)
return None
main_pm = await get_agent_service(self.session).get_by_slug("main-pm")
if main_pm is None:
@@ -52,6 +52,8 @@ async def start_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
def _task(
status: TaskStatus = TaskStatus.PENDING,
assigned_to: UUID | None = None,
team: Team = Team.MAIN_PM,
board_review_complete: bool = False,
) -> TaskTable:
t = TaskTable(
id=uuid4(),
@@ -64,7 +66,8 @@ async def start_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
nature=TaskNature.TECHNICAL,
project_id=project.id,
created_by=po.id,
team=Team.MAIN_PM,
team=team,
board_review_complete=board_review_complete,
assigned_to=assigned_to if assigned_to else po.id,
)
db_session.add(t)
@@ -122,3 +125,23 @@ async def test_returns_none_when_not_pending(start_setup: dict) -> None:
await start_setup["db"].flush()
out = await start_setup["svc"].approve_and_start(task.id, "x" * 25)
assert out is None
@pytest.mark.asyncio
async def test_returns_none_when_board_review_incomplete(start_setup: dict) -> None:
# A task still on the board with an unfinished review must not be started.
task = start_setup["mk"](team=Team.BOARD, board_review_complete=False)
await start_setup["db"].flush()
out = await start_setup["svc"].approve_and_start(task.id, "x" * 25)
assert out is None
assert task.assigned_to == start_setup["po"].id # not handed to Main PM
@pytest.mark.asyncio
async def test_succeeds_when_board_review_complete(start_setup: dict) -> None:
task = start_setup["mk"](team=Team.BOARD, board_review_complete=True)
await start_setup["db"].flush()
out = await start_setup["svc"].approve_and_start(task.id, "x" * 25)
assert out is not None
assert out.assigned_to == start_setup["main_pm"].id
assert out.status == TaskStatus.PENDING
@@ -0,0 +1,229 @@
from __future__ import annotations
from datetime import UTC, datetime
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.tasks import router as tasks_router
from roboco.db.tables import (
AgentTable,
JournalEntryTable,
JournalTable,
ProjectTable,
TaskTable,
)
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import JournalEntryType, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.journal import JournalService
_HDR_PM = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
def _agent(slug: str, role: AgentRole) -> AgentTable:
return AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
@pytest_asyncio.fixture
async def brief_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
po = _agent(f"product-owner-{uuid4().hex[:4]}", AgentRole.PRODUCT_OWNER)
hom = _agent(f"head-marketing-{uuid4().hex[:4]}", AgentRole.HEAD_MARKETING)
dev = _agent(f"be-dev-{uuid4().hex[:4]}", AgentRole.DEVELOPER)
db_session.add_all([po, hom, dev])
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=po.id,
)
db_session.add(project)
await db_session.flush()
def _task() -> TaskTable:
t = TaskTable(
id=uuid4(),
title="Board task",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=project.id,
created_by=po.id,
team=Team.BOARD,
)
db_session.add(t)
return t
task = _task()
other = _task()
await db_session.flush()
journals = {a.id: JournalTable(id=uuid4(), agent_id=a.id) for a in (po, hom, dev)}
db_session.add_all(journals.values())
await db_session.flush()
def _entry(
agent: AgentTable,
*,
entry_type: JournalEntryType,
task_id,
title: str,
when: datetime,
) -> None:
db_session.add(
JournalEntryTable(
id=uuid4(),
journal_id=journals[agent.id].id,
type=entry_type,
title=title,
content=f"{title} — body",
task_id=task_id,
timestamp=when,
tags=["decision"],
)
)
# HoM logs after PO so we can assert oldest-first ordering returns PO then HoM.
_entry(
po,
entry_type=JournalEntryType.DECISION_LOG,
task_id=task.id,
title="PO review",
when=datetime(2026, 1, 1, tzinfo=UTC),
)
_entry(
hom,
entry_type=JournalEntryType.DECISION_LOG,
task_id=task.id,
title="HoM review",
when=datetime(2026, 1, 2, tzinfo=UTC),
)
# Noise that must be excluded:
_entry( # non-board author, decision log
dev,
entry_type=JournalEntryType.DECISION_LOG,
task_id=task.id,
title="Dev decision",
when=datetime(2026, 1, 3, tzinfo=UTC),
)
_entry( # board author, but not a decision log
po,
entry_type=JournalEntryType.TASK_REFLECTION,
task_id=task.id,
title="PO reflection",
when=datetime(2026, 1, 4, tzinfo=UTC),
)
_entry( # board decision log, but on a different task
po,
entry_type=JournalEntryType.DECISION_LOG,
task_id=other.id,
title="PO review of other task",
when=datetime(2026, 1, 5, tzinfo=UTC),
)
await db_session.flush()
yield {"svc": JournalService(db_session), "task": task, "other": other}
@pytest.mark.asyncio
async def test_brief_returns_only_board_decision_logs_oldest_first(
brief_setup: dict,
) -> None:
brief = await brief_setup["svc"].board_review_brief(brief_setup["task"].id)
titles = [e["title"] for e in brief]
assert titles == ["PO review", "HoM review"] # ordered, filtered
assert brief[0]["author_role"] == "product_owner"
assert brief[1]["author_role"] == "head_marketing"
assert all("body" in e["content"] for e in brief)
@pytest.mark.asyncio
async def test_brief_empty_when_no_board_review(brief_setup: dict) -> None:
# `other` only has a single PO decision log on it (created as noise above),
# so build a genuinely un-reviewed task to assert the empty case.
fresh = TaskTable(
id=uuid4(),
title="Unreviewed",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
created_by=brief_setup["task"].created_by,
team=Team.BOARD,
)
brief_setup["svc"].session.add(fresh)
await brief_setup["svc"].session.flush()
assert await brief_setup["svc"].board_review_brief(fresh.id) == []
def _board_review_app(db_session: AsyncSession) -> FastAPI:
app = FastAPI()
app.include_router(tasks_router, prefix="/api/tasks")
async def _db():
yield db_session
async def _agent() -> AgentContext:
return AgentContext(agent_id=uuid4(), role=AgentRole.MAIN_PM, team=None)
app.dependency_overrides[get_db] = _db
app.dependency_overrides[get_agent_context] = _agent
return app
@pytest.mark.asyncio
async def test_board_review_endpoint_returns_entries(
brief_setup: dict, db_session: AsyncSession
) -> None:
app = _board_review_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
f"/api/tasks/{brief_setup['task'].id}/board-review", headers=_HDR_PM
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert [e["title"] for e in body] == ["PO review", "HoM review"]
assert body[0]["author_role"] == "product_owner"
@pytest.mark.asyncio
async def test_board_review_endpoint_404_for_missing_task(
db_session: AsyncSession,
) -> None:
app = _board_review_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/tasks/{uuid4()}/board-review", headers=_HDR_PM)
assert resp.status_code == HTTPStatus.NOT_FOUND
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import TaskNature, TaskStatus, TaskType
from roboco.services.base import NotFoundError
from roboco.services.prompter import (
PrompterService,
compose_redraft_message,
format_board_briefing,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
# --------------------------------------------------------------------------- #
# Pure helpers (no DB)
# --------------------------------------------------------------------------- #
def test_format_board_briefing_labels_and_orders() -> None:
entries = [
{"author_role": "product_owner", "author": "po", "title": "PO", "content": "x"},
{
"author_role": "head_marketing",
"author": "hom",
"title": "HoM",
"content": "y",
},
]
out = format_board_briefing(entries)
assert "Product Owner" in out
assert "Head of Marketing" in out
assert out.index("Product Owner") < out.index("Head of Marketing")
assert "x" in out and "y" in out
def test_format_board_briefing_empty() -> None:
assert format_board_briefing([]) == ""
def test_compose_redraft_message_includes_draft_and_brief() -> None:
task = SimpleNamespace(
title="My Task",
description="The current description.",
acceptance_criteria=["does X", "does Y"],
)
entries = [
{"author_role": "product_owner", "author": "po", "title": "PO", "content": "z"}
]
msg = compose_redraft_message(task, entries)
assert "My Task" in msg
assert "The current description." in msg
assert "- does X" in msg
assert "Product Owner" in msg
assert "z" in msg
# --------------------------------------------------------------------------- #
# update_live_draft (DB)
# --------------------------------------------------------------------------- #
@pytest_asyncio.fixture
async def redraft_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
def _agent(slug: str, role: AgentRole) -> AgentTable:
return AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
main_pm = _agent("main-pm", AgentRole.MAIN_PM)
po = _agent(f"product-owner-{uuid4().hex[:4]}", AgentRole.PRODUCT_OWNER)
db_session.add_all([main_pm, po])
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=po.id,
)
db_session.add(project)
await db_session.flush()
def _board_task(board_review_complete: bool) -> TaskTable:
t = TaskTable(
id=uuid4(),
title="Original title",
description="Original description, long enough.",
acceptance_criteria=["original ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=project.id,
created_by=po.id,
team=Team.BOARD,
board_review_complete=board_review_complete,
assigned_to=po.id,
)
db_session.add(t)
return t
yield {
"svc": PrompterService(db_session),
"db": db_session,
"main_pm": main_pm,
"po": po,
"mk": _board_task,
}
_DRAFT = {
"title": "Revised title",
"objective": "Revised objective after board feedback for the task at hand.",
"acceptance_criteria": ["revised ac one", "revised ac two"],
"description": "Revised description that is comfortably over twenty chars.",
}
@pytest.mark.asyncio
async def test_update_live_draft_main_pm_updates_and_hands_off(
redraft_setup: dict,
) -> None:
task = redraft_setup["mk"](True) # board review complete
await redraft_setup["db"].flush()
out_id = await redraft_setup["svc"].update_live_draft(
task.id, _DRAFT, route="main_pm"
)
assert out_id == task.id
assert task.title == "Revised title"
assert task.acceptance_criteria == ["revised ac one", "revised ac two"]
assert "Revised" in task.description
# Approve & Start ran → handed to Main PM.
assert task.assigned_to == redraft_setup["main_pm"].id
assert task.team == Team.MAIN_PM
@pytest.mark.asyncio
async def test_update_live_draft_reboard_resets_flag(redraft_setup: dict) -> None:
task = redraft_setup["mk"](True)
await redraft_setup["db"].flush()
await redraft_setup["svc"].update_live_draft(task.id, _DRAFT, route="board")
assert task.title == "Revised title"
assert task.board_review_complete is False # back for another review round
assert task.assigned_to == redraft_setup["po"].id # still on the board
assert task.team == Team.BOARD
@pytest.mark.asyncio
async def test_update_live_draft_missing_task_raises(redraft_setup: dict) -> None:
with pytest.raises(NotFoundError):
await redraft_setup["svc"].update_live_draft(uuid4(), _DRAFT, route="main_pm")
+24
View File
@@ -22,6 +22,30 @@ def test_open_get_close() -> None:
assert reg.get("s1") is None
def test_park_and_find_by_task() -> None:
"""A parked session is discoverable by task id for board-feedback injection."""
reg = PrompterLiveRegistry()
session = reg.open("s1", "intake-1")
assert reg.park("s1", "task-abc") is True
assert session.task_id == "task-abc"
assert reg.find_by_task("task-abc") is session
assert reg.find_by_task("task-other") is None
def test_park_missing_session_returns_false() -> None:
reg = PrompterLiveRegistry()
assert reg.park("nope", "task-abc") is False
def test_find_by_task_ignores_closed_session() -> None:
"""A reaped parked session is not returned (the cold re-draft path covers it)."""
reg = PrompterLiveRegistry()
reg.open("s1", "intake-1")
reg.park("s1", "task-abc")
reg.close("s1")
assert reg.find_by_task("task-abc") is None
def test_is_alive_tracks_open_and_close() -> None:
"""is_alive backs the panel's after-reload reconnect decision."""
reg = PrompterLiveRegistry()