mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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 & 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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user