feat(board): gate CEO Approve & Start on board-review completion

A board/coordination task stays pending throughout board review — that
pending state is what hands it to Main PM on approval — so the CEO's
Approve & Start button was live from the instant the task was created,
before the Product Owner and Head of Marketing had reviewed anything.
That let the CEO approve before the board finished.

Persist a board_review_complete flag the orchestrator sets once BOTH
board reviewers are done, and gate the button on it (the task stays
pending). The same handoff emits the formal CEO notification, so the
CEO gets an actionable signal instead of buried channel chatter.

- alembic 021: add tasks.board_review_complete (default false)
- TaskService.mark_board_review_complete: set the flag without leaving pending
- orchestrator: flag the task + notify CEO once both reviewers go idle
- panel: Approve & Start requires board_review_complete
This commit is contained in:
Renn F
2026-06-03 08:06:41 +02:00
parent 056ff41293
commit ceb4eec6ca
10 changed files with 181 additions and 69 deletions
@@ -0,0 +1,40 @@
"""Add tasks.board_review_complete — the board-review handoff flag.
A board/coordination task (no repo of its own, carries a product) is reviewed
by BOTH the Product Owner and the Head of Marketing before the CEO hands it to
Main PM. The task stays ``pending`` throughout — that pending state is what
drives Main PM dispatch once the CEO approves. The CEO's Approve & Start button
must NOT appear until the board has actually finished reviewing, so we persist a
flag the orchestrator sets once both reviewers are done. Defaults to False;
existing rows backfill to False (no board task has been reviewed retroactively).
Revision ID: 021_task_board_review_complete
Revises: 020_backfill_enum_values
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "021_task_board_review_complete"
down_revision = "020_backfill_enum_values"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column(
"board_review_complete",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("tasks", "board_review_complete")
@@ -364,16 +364,20 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
{/* CEO gate #1: Approve & Start a board-reviewed coordination task.
This is the handoff for a board/fan-out task (a product, no repo of its
own) that the Board has reviewed and is waiting on the CEO to hand to
Main PM. The server's approve_and_start requires the task to still be
PENDING (it re-targets to Main PM without a status change), so we gate
on PENDING — NOT awaiting_ceo_approval, which is the unrelated
end-of-work CEO gate handled by the ceo-approve flow. We also require a
coordination task (no project_id, has product_id) so the button only
shows on the board's fan-out handoffs, not on every board-team task. */}
Main PM. The server's approve_and_start keeps the task PENDING (it
re-targets to Main PM without a status change — that pending state is
what drives Main PM dispatch), so we gate on PENDING here, NOT on
awaiting_ceo_approval (the unrelated end-of-work ceo-approve flow).
The button must not appear until the board has actually finished
reviewing, so we also require board_review_complete — the flag the
orchestrator sets once BOTH the PO and Head of Marketing are done. The
coordination predicate (no project_id, has product_id) keeps the
button to the board's fan-out handoffs, not every board-team task. */}
{task.status === TaskStatus.PENDING &&
task.team === Team.BOARD &&
!task.project_id &&
!!task.product_id && (
!!task.product_id &&
task.board_review_complete === true && (
<div className="flex justify-end">
<ApproveAndStartButton task={task} />
</div>
+3
View File
@@ -280,6 +280,9 @@ export interface Task {
// PR Tracking (parallel execution in awaiting_documentation)
docs_complete: boolean;
pr_created: boolean;
// True once PO + Head of Marketing have both reviewed a pending board task.
// Gates the CEO's Approve & Start button (the task stays pending throughout).
board_review_complete?: boolean;
pm_approvals: Record<string, boolean>;
// Planning
plan: TaskPlan | null;
+5
View File
@@ -270,6 +270,10 @@ class TaskResponse(BaseModel):
docs_complete: bool = False # Documenter has finished
pr_created: bool = False # Developer has created PR
# Board review handoff: True once PO + Head of Marketing have both reviewed
# a pending board/coordination task. Gates the CEO's Approve & Start button.
board_review_complete: bool = False
# Ownership
team: Team
created_by: UUID
@@ -640,6 +644,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
),
docs_complete=task.docs_complete,
pr_created=task.pr_created,
board_review_complete=task.board_review_complete,
team=task.team,
created_by=require_uuid(task.created_by),
assigned_to=to_python_uuid(task.assigned_to),
+8
View File
@@ -229,6 +229,14 @@ class TaskTable(Base):
docs_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pr_created: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Board review handoff: a board/coordination task stays pending while the
# Product Owner + Head of Marketing review it. Set True once both reviewers
# finish, so the CEO's Approve & Start button appears only after the board
# is actually done — never on a freshly created pending board task.
board_review_complete: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# Ownership
created_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False
+34 -22
View File
@@ -4189,11 +4189,11 @@ Start now: evidence(task_id="{task_id}")
) -> None:
"""Review an assigned board task with the FULL board (PO + HoM), ONCE each.
Cluster C5 / finding #4: a board/coordination task — especially one with
a UI / user-facing dimension must be reviewed by BOTH the Product
Owner AND the Head of Marketing before it is handed to the CEO. The task
is assigned to one board agent, but the review is a two-reviewer gate, so
this dispatches both regardless of which one ``assigned_to`` names.
A board/coordination task especially one with a UI / user-facing
dimension must be reviewed by BOTH the Product Owner AND the Head of
Marketing before it is handed to the CEO. The task is assigned to one
board agent, but the review is a two-reviewer gate, so this dispatches
both regardless of which one ``assigned_to`` names.
Board roles advise: they can triage, record notes, and discuss, but have
NO verb to claim, plan, delegate, or complete. A respawn cannot advance
@@ -4202,8 +4202,9 @@ Start now: evidence(task_id="{task_id}")
hands the task to Main PM for delegation to the cells.
Once BOTH reviewers have finished (each dispatched and no longer active),
a single formal CEO notification is emitted (finding #2) so the handoff
to Approve & Start is an actionable signal rather than buried chatter.
the board-review handoff fires: the task is flagged board-reviewed and a
single formal CEO notification is emitted so Approve & Start is an
actionable signal rather than buried chatter.
"""
# `assigned_to` only gates that this IS a board task; the review itself
# always involves the whole board, not just the named assignee.
@@ -4212,7 +4213,7 @@ Start now: evidence(task_id="{task_id}")
task_id = str(task.get("id"))
for board_slug in sorted(self._BOARD_AGENTS):
await self._dispatch_board_reviewer(board_slug, task_id, task)
await self._maybe_notify_ceo_board_review_complete(task_id)
await self._maybe_handoff_board_review_to_ceo(task_id)
async def _dispatch_board_reviewer(
self, board_slug: str, task_id: str, task: dict[str, Any]
@@ -4255,41 +4256,52 @@ Start now: evidence(task_id="{task_id}")
for board_slug in self._BOARD_AGENTS
)
async def _maybe_notify_ceo_board_review_complete(self, task_id: str) -> None:
"""Emit a one-shot CEO notification when the board review is complete.
async def _maybe_handoff_board_review_to_ceo(self, task_id: str) -> None:
"""Unlock the CEO's Approve & Start gate when the board review is done.
Board roles are exactly the senders permitted to issue formal
notifications, but the board agents only post channel dialogue + journal
notes during their review (finding #2: the CEO got count=0 notifications
after the PO finished). The orchestrator closes that gap: once both
reviewers are done, it emits an APPROVAL notification (ack-required, with
``related_task_id``) to the CEO on the board's behalf. Fires exactly once
per task; notification failure is logged and swallowed so it never
blocks the dispatch loop.
Two one-shot effects fire once BOTH board reviewers have finished:
1. Persist ``board_review_complete`` on the task. The task stays
pending (its pending state is what hands it to Main PM on approval),
so this flag is the only thing that makes the CEO's Approve & Start
button appear it never shows on a board task the board hasn't
finished reviewing.
2. Emit an ack-required APPROVAL notification to the CEO. Board agents
only post channel dialogue + journal notes during review, which
left the CEO with no actionable signal; this is that signal.
Fires at most once per task; a failure clears the guard so a later tick
retries, and never blocks the dispatch loop.
"""
if task_id in self._board_review_ceo_notified:
return
if not self._board_review_complete(task_id):
return
self._board_review_ceo_notified.add(task_id)
from uuid import UUID
from roboco.db.base import get_db_context
from roboco.services.notification import NotificationService
from roboco.services.task import TaskService
try:
async with get_db_context() as db:
await TaskService(db).mark_board_review_complete(UUID(task_id))
await db.commit()
await NotificationService().send_board_review_complete_notification(
task_id=task_id,
)
except Exception as exc:
# Don't wedge dispatch on a notification failure; allow a retry by
# clearing the one-shot guard so a later tick can re-emit.
# Don't wedge dispatch on a failure; allow a retry by clearing the
# one-shot guard so a later tick can re-run the handoff.
self._board_review_ceo_notified.discard(task_id)
logger.warning(
"Failed to notify CEO of board-review completion",
"Failed to hand board-review completion to CEO",
task_id=task_id,
error=str(exc),
)
return
logger.info(
"Notified CEO that board review is complete (ready for Approve & Start)",
"Board review complete — CEO Approve & Start unlocked",
task_id=task_id,
)
+1 -1
View File
@@ -218,7 +218,7 @@ class NotificationService:
CEO's Approve & Start gate (``TaskService.approve_and_start``). The
Product Owner + Head of Marketing record their review via channel
dialogue and journal notes, but that left the CEO with no actionable
signal only buried chatter (cluster C5 / finding #2). This emits a
signal only buried chatter. This emits a
formal APPROVAL notification (ack-required) carrying ``related_task_id``
so the handoff is a real signal the panel can surface, not channel
noise. Board roles are exactly the senders permitted to notify, so the
+16
View File
@@ -3529,6 +3529,22 @@ class TaskService(BaseService):
)
return task
async def mark_board_review_complete(self, task_id: UUID) -> bool:
"""Flag a board task as board-reviewed without moving it off pending.
The task stays pending (that pending state is what makes
``approve_and_start`` hand it to Main PM). This flag only unlocks the
CEO's Approve & Start button, so the button never shows on a board task
the PO + Head of Marketing haven't finished reviewing. Idempotent;
returns True when it flips the flag, False when already set or missing.
"""
task = await self.get(task_id)
if task is None or task.board_review_complete:
return False
task.board_review_complete = True
await self.session.flush()
return True
async def ceo_reject(
self,
task_id: UUID,
+1
View File
@@ -277,6 +277,7 @@ def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
project=(SimpleNamespace(slug="proj-1") if with_project else None),
docs_complete=False,
pr_created=False,
board_review_complete=False,
team=Team.BACKEND,
created_by=uuid4(),
assigned_to=None,
+62 -39
View File
@@ -1,17 +1,18 @@
"""Board agents (Product Owner + Head of Marketing) review board-team tasks.
Cluster C5:
- A board/coordination task is a TWO-reviewer gate: BOTH the Product Owner and
the Head of Marketing must review it before it reaches the CEO (finding #4).
Each reviewer is dispatched ONCE board roles have no verb to claim/plan/
delegate/complete, so a respawn cannot advance the task and would just loop.
- Once BOTH reviewers have finished, the orchestrator emits exactly ONE formal
CEO notification so the handoff to Approve & Start is an actionable signal,
not buried channel chatter (finding #2).
the Head of Marketing must review it before it reaches the CEO. Each reviewer
is dispatched ONCE board roles have no verb to claim/plan/delegate/complete,
so a respawn cannot advance the task and would just loop.
- Once BOTH reviewers have finished, the orchestrator hands the review to the
CEO: it flags the (still-pending) task ``board_review_complete`` so the CEO's
Approve & Start button appears, and emits exactly one formal CEO notification
so the handoff is an actionable signal, not buried channel chatter.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import uuid4
@@ -39,10 +40,27 @@ def _board_task(assigned_to: str) -> dict[str, Any]:
}
def _patch_handoff_db(task_svc: AsyncMock):
"""Patch the DB context + TaskService the handoff opens to flag the task.
Returns a tuple of context managers for the caller's ``with`` block so the
direct-call tests exercise the real handoff body without touching a DB.
"""
@asynccontextmanager
async def _fake_ctx():
yield AsyncMock()
return (
patch("roboco.db.base.get_db_context", _fake_ctx),
patch("roboco.services.task.TaskService", return_value=task_svc),
)
@pytest.mark.asyncio
async def test_both_board_agents_dispatched_for_board_task() -> None:
"""A board task must dispatch BOTH the PO and the Head of Marketing — the
review is a two-reviewer gate, not a single-assignee claim (finding #4)."""
review is a two-reviewer gate, not a single-assignee claim."""
orch = _make_orch()
task = _board_task("product-owner")
with (
@@ -50,7 +68,7 @@ async def test_both_board_agents_dispatched_for_board_task() -> None:
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
"_maybe_handoff_board_review_to_ceo",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
@@ -73,7 +91,7 @@ async def test_each_board_agent_spawned_only_once() -> None:
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
"_maybe_handoff_board_review_to_ceo",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
@@ -100,7 +118,7 @@ async def test_board_handler_skips_active_reviewer_but_dispatches_other() -> Non
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
"_maybe_handoff_board_review_to_ceo",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
@@ -126,8 +144,8 @@ async def test_board_handler_ignores_non_board_assignee() -> None:
@pytest.mark.asyncio
async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None:
"""An UNASSIGNED board task must route through the board handler so BOTH
reviewers are dispatched not claimed + single-spawned for the PO only
(finding #4). The task stays unclaimed for the CEO's Approve & Start."""
reviewers are dispatched not claimed + single-spawned for the PO only.
The task stays unclaimed for the CEO's Approve & Start."""
orch = _make_orch()
task = {
"id": str(uuid4()),
@@ -144,7 +162,7 @@ async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
"_maybe_handoff_board_review_to_ceo",
new=AsyncMock(),
),
patch.object(
@@ -190,26 +208,28 @@ def test_board_review_not_complete_while_a_reviewer_active() -> None:
@pytest.mark.asyncio
async def test_ceo_notified_once_when_board_review_complete() -> None:
"""Finding #2: a formal CEO notification fires exactly once when both
board reviewers have finished."""
async def test_ceo_handoff_once_when_board_review_complete() -> None:
"""When both reviewers finish, the handoff flags the task board-reviewed and
fires exactly one CEO notification."""
orch = _make_orch()
task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id))
orch._board_dispatched.add(("head-marketing", task_id))
svc = AsyncMock()
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
patch("roboco.services.notification.NotificationService", return_value=svc),
db_ctx,
task_ctx,
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
# Second tick: already notified — must not re-emit.
await orch._maybe_notify_ceo_board_review_complete(task_id)
await orch._maybe_handoff_board_review_to_ceo(task_id)
# Second tick: already handed off — must not re-emit.
await orch._maybe_handoff_board_review_to_ceo(task_id)
task_svc.mark_board_review_complete.assert_awaited_once()
svc.send_board_review_complete_notification.assert_awaited_once_with(
task_id=task_id
)
@@ -217,30 +237,32 @@ async def test_ceo_notified_once_when_board_review_complete() -> None:
@pytest.mark.asyncio
async def test_ceo_not_notified_while_review_incomplete() -> None:
"""No CEO notification until BOTH reviewers are done."""
async def test_ceo_not_handed_off_while_review_incomplete() -> None:
"""No flag and no CEO notification until BOTH reviewers are done."""
orch = _make_orch()
task_id = str(uuid4())
# Only PO has been dispatched/finished.
orch._board_dispatched.add(("product-owner", task_id))
svc = AsyncMock()
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
patch("roboco.services.notification.NotificationService", return_value=svc),
db_ctx,
task_ctx,
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
await orch._maybe_handoff_board_review_to_ceo(task_id)
task_svc.mark_board_review_complete.assert_not_awaited()
svc.send_board_review_complete_notification.assert_not_awaited()
assert task_id not in orch._board_review_ceo_notified
@pytest.mark.asyncio
async def test_ceo_notify_failure_allows_retry() -> None:
"""A notification failure clears the one-shot guard so a later tick retries."""
async def test_ceo_handoff_failure_allows_retry() -> None:
"""A handoff failure clears the one-shot guard so a later tick retries."""
orch = _make_orch()
task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id))
@@ -248,16 +270,17 @@ async def test_ceo_notify_failure_allows_retry() -> None:
svc = AsyncMock()
svc.send_board_review_complete_notification.side_effect = RuntimeError("db down")
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
patch("roboco.services.notification.NotificationService", return_value=svc),
db_ctx,
task_ctx,
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
await orch._maybe_handoff_board_review_to_ceo(task_id)
# Guard cleared so a later, healthy tick can re-emit.
# Guard cleared so a later, healthy tick can re-run the handoff.
assert task_id not in orch._board_review_ceo_notified