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
+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: