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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user