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. {/* 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 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 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 Main PM. The server's approve_and_start keeps the task PENDING (it
PENDING (it re-targets to Main PM without a status change), so we gate re-targets to Main PM without a status change — that pending state is
on PENDING — NOT awaiting_ceo_approval, which is the unrelated what drives Main PM dispatch), so we gate on PENDING here, NOT on
end-of-work CEO gate handled by the ceo-approve flow. We also require a awaiting_ceo_approval (the unrelated end-of-work ceo-approve flow).
coordination task (no project_id, has product_id) so the button only The button must not appear until the board has actually finished
shows on the board's fan-out handoffs, not on every board-team task. */} 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.status === TaskStatus.PENDING &&
task.team === Team.BOARD && task.team === Team.BOARD &&
!task.project_id && !task.project_id &&
!!task.product_id && ( !!task.product_id &&
task.board_review_complete === true && (
<div className="flex justify-end"> <div className="flex justify-end">
<ApproveAndStartButton task={task} /> <ApproveAndStartButton task={task} />
</div> </div>
+3
View File
@@ -280,6 +280,9 @@ export interface Task {
// PR Tracking (parallel execution in awaiting_documentation) // PR Tracking (parallel execution in awaiting_documentation)
docs_complete: boolean; docs_complete: boolean;
pr_created: 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>; pm_approvals: Record<string, boolean>;
// Planning // Planning
plan: TaskPlan | null; plan: TaskPlan | null;
+5
View File
@@ -270,6 +270,10 @@ class TaskResponse(BaseModel):
docs_complete: bool = False # Documenter has finished docs_complete: bool = False # Documenter has finished
pr_created: bool = False # Developer has created PR 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 # Ownership
team: Team team: Team
created_by: UUID created_by: UUID
@@ -640,6 +644,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
), ),
docs_complete=task.docs_complete, docs_complete=task.docs_complete,
pr_created=task.pr_created, pr_created=task.pr_created,
board_review_complete=task.board_review_complete,
team=task.team, team=task.team,
created_by=require_uuid(task.created_by), created_by=require_uuid(task.created_by),
assigned_to=to_python_uuid(task.assigned_to), 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) docs_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pr_created: 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 # Ownership
created_by: Mapped[UUID] = mapped_column( created_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False 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: ) -> None:
"""Review an assigned board task with the FULL board (PO + HoM), ONCE each. """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 board/coordination task especially one with a UI / user-facing
a UI / user-facing dimension must be reviewed by BOTH the Product dimension must be reviewed by BOTH the Product Owner AND the Head of
Owner AND the Head of Marketing before it is handed to the CEO. The task Marketing before it is handed to the CEO. The task is assigned to one
is assigned to one board agent, but the review is a two-reviewer gate, so board agent, but the review is a two-reviewer gate, so this dispatches
this dispatches both regardless of which one ``assigned_to`` names. both regardless of which one ``assigned_to`` names.
Board roles advise: they can triage, record notes, and discuss, but have Board roles advise: they can triage, record notes, and discuss, but have
NO verb to claim, plan, delegate, or complete. A respawn cannot advance 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. hands the task to Main PM for delegation to the cells.
Once BOTH reviewers have finished (each dispatched and no longer active), Once BOTH reviewers have finished (each dispatched and no longer active),
a single formal CEO notification is emitted (finding #2) so the handoff the board-review handoff fires: the task is flagged board-reviewed and a
to Approve & Start is an actionable signal rather than buried chatter. 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 # `assigned_to` only gates that this IS a board task; the review itself
# always involves the whole board, not just the named assignee. # 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")) task_id = str(task.get("id"))
for board_slug in sorted(self._BOARD_AGENTS): for board_slug in sorted(self._BOARD_AGENTS):
await self._dispatch_board_reviewer(board_slug, task_id, task) 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( async def _dispatch_board_reviewer(
self, board_slug: str, task_id: str, task: dict[str, Any] 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 for board_slug in self._BOARD_AGENTS
) )
async def _maybe_notify_ceo_board_review_complete(self, task_id: str) -> None: async def _maybe_handoff_board_review_to_ceo(self, task_id: str) -> None:
"""Emit a one-shot CEO notification when the board review is complete. """Unlock the CEO's Approve & Start gate when the board review is done.
Board roles are exactly the senders permitted to issue formal Two one-shot effects fire once BOTH board reviewers have finished:
notifications, but the board agents only post channel dialogue + journal 1. Persist ``board_review_complete`` on the task. The task stays
notes during their review (finding #2: the CEO got count=0 notifications pending (its pending state is what hands it to Main PM on approval),
after the PO finished). The orchestrator closes that gap: once both so this flag is the only thing that makes the CEO's Approve & Start
reviewers are done, it emits an APPROVAL notification (ack-required, with button appear it never shows on a board task the board hasn't
``related_task_id``) to the CEO on the board's behalf. Fires exactly once finished reviewing.
per task; notification failure is logged and swallowed so it never 2. Emit an ack-required APPROVAL notification to the CEO. Board agents
blocks the dispatch loop. 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: if task_id in self._board_review_ceo_notified:
return return
if not self._board_review_complete(task_id): if not self._board_review_complete(task_id):
return return
self._board_review_ceo_notified.add(task_id) 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.notification import NotificationService
from roboco.services.task import TaskService
try: 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( await NotificationService().send_board_review_complete_notification(
task_id=task_id, task_id=task_id,
) )
except Exception as exc: except Exception as exc:
# Don't wedge dispatch on a notification failure; allow a retry by # Don't wedge dispatch on a failure; allow a retry by clearing the
# clearing the one-shot guard so a later tick can re-emit. # one-shot guard so a later tick can re-run the handoff.
self._board_review_ceo_notified.discard(task_id) self._board_review_ceo_notified.discard(task_id)
logger.warning( logger.warning(
"Failed to notify CEO of board-review completion", "Failed to hand board-review completion to CEO",
task_id=task_id, task_id=task_id,
error=str(exc), error=str(exc),
) )
return return
logger.info( logger.info(
"Notified CEO that board review is complete (ready for Approve & Start)", "Board review complete — CEO Approve & Start unlocked",
task_id=task_id, task_id=task_id,
) )
+1 -1
View File
@@ -218,7 +218,7 @@ class NotificationService:
CEO's Approve & Start gate (``TaskService.approve_and_start``). The CEO's Approve & Start gate (``TaskService.approve_and_start``). The
Product Owner + Head of Marketing record their review via channel Product Owner + Head of Marketing record their review via channel
dialogue and journal notes, but that left the CEO with no actionable 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`` formal APPROVAL notification (ack-required) carrying ``related_task_id``
so the handoff is a real signal the panel can surface, not channel 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 noise. Board roles are exactly the senders permitted to notify, so the
+16
View File
@@ -3529,6 +3529,22 @@ class TaskService(BaseService):
) )
return task 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( async def ceo_reject(
self, self,
task_id: UUID, 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), project=(SimpleNamespace(slug="proj-1") if with_project else None),
docs_complete=False, docs_complete=False,
pr_created=False, pr_created=False,
board_review_complete=False,
team=Team.BACKEND, team=Team.BACKEND,
created_by=uuid4(), created_by=uuid4(),
assigned_to=None, assigned_to=None,
+62 -39
View File
@@ -1,17 +1,18 @@
"""Board agents (Product Owner + Head of Marketing) review board-team tasks. """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 - 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). the Head of Marketing must review it before it reaches the CEO. Each reviewer
Each reviewer is dispatched ONCE board roles have no verb to claim/plan/ is dispatched ONCE board roles have no verb to claim/plan/delegate/complete,
delegate/complete, so a respawn cannot advance the task and would just loop. so a respawn cannot advance the task and would just loop.
- Once BOTH reviewers have finished, the orchestrator emits exactly ONE formal - Once BOTH reviewers have finished, the orchestrator hands the review to the
CEO notification so the handoff to Approve & Start is an actionable signal, CEO: it flags the (still-pending) task ``board_review_complete`` so the CEO's
not buried channel chatter (finding #2). 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 __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from uuid import uuid4 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 @pytest.mark.asyncio
async def test_both_board_agents_dispatched_for_board_task() -> None: 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 """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() orch = _make_orch()
task = _board_task("product-owner") task = _board_task("product-owner")
with ( 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, "_task_git_context", return_value=None),
patch.object( patch.object(
orch, orch,
"_maybe_notify_ceo_board_review_complete", "_maybe_handoff_board_review_to_ceo",
new=AsyncMock(), new=AsyncMock(),
), ),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, 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, "_task_git_context", return_value=None),
patch.object( patch.object(
orch, orch,
"_maybe_notify_ceo_board_review_complete", "_maybe_handoff_board_review_to_ceo",
new=AsyncMock(), new=AsyncMock(),
), ),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, 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, "_task_git_context", return_value=None),
patch.object( patch.object(
orch, orch,
"_maybe_notify_ceo_board_review_complete", "_maybe_handoff_board_review_to_ceo",
new=AsyncMock(), new=AsyncMock(),
), ),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn, 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 @pytest.mark.asyncio
async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None: async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None:
"""An UNASSIGNED board task must route through the board handler so BOTH """An UNASSIGNED board task must route through the board handler so BOTH
reviewers are dispatched not claimed + single-spawned for the PO only reviewers are dispatched not claimed + single-spawned for the PO only.
(finding #4). The task stays unclaimed for the CEO's Approve & Start.""" The task stays unclaimed for the CEO's Approve & Start."""
orch = _make_orch() orch = _make_orch()
task = { task = {
"id": str(uuid4()), "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, "_task_git_context", return_value=None),
patch.object( patch.object(
orch, orch,
"_maybe_notify_ceo_board_review_complete", "_maybe_handoff_board_review_to_ceo",
new=AsyncMock(), new=AsyncMock(),
), ),
patch.object( patch.object(
@@ -190,26 +208,28 @@ def test_board_review_not_complete_while_a_reviewer_active() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ceo_notified_once_when_board_review_complete() -> None: async def test_ceo_handoff_once_when_board_review_complete() -> None:
"""Finding #2: a formal CEO notification fires exactly once when both """When both reviewers finish, the handoff flags the task board-reviewed and
board reviewers have finished.""" fires exactly one CEO notification."""
orch = _make_orch() orch = _make_orch()
task_id = str(uuid4()) task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("product-owner", task_id))
orch._board_dispatched.add(("head-marketing", task_id)) orch._board_dispatched.add(("head-marketing", task_id))
svc = AsyncMock() svc = AsyncMock()
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with ( with (
patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_is_agent_active", return_value=False),
patch( patch("roboco.services.notification.NotificationService", return_value=svc),
"roboco.services.notification.NotificationService", db_ctx,
return_value=svc, task_ctx,
),
): ):
await orch._maybe_notify_ceo_board_review_complete(task_id) await orch._maybe_handoff_board_review_to_ceo(task_id)
# Second tick: already notified — must not re-emit. # Second tick: already handed off — must not re-emit.
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_awaited_once()
svc.send_board_review_complete_notification.assert_awaited_once_with( svc.send_board_review_complete_notification.assert_awaited_once_with(
task_id=task_id task_id=task_id
) )
@@ -217,30 +237,32 @@ async def test_ceo_notified_once_when_board_review_complete() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ceo_not_notified_while_review_incomplete() -> None: async def test_ceo_not_handed_off_while_review_incomplete() -> None:
"""No CEO notification until BOTH reviewers are done.""" """No flag and no CEO notification until BOTH reviewers are done."""
orch = _make_orch() orch = _make_orch()
task_id = str(uuid4()) task_id = str(uuid4())
# Only PO has been dispatched/finished. # Only PO has been dispatched/finished.
orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("product-owner", task_id))
svc = AsyncMock() svc = AsyncMock()
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with ( with (
patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_is_agent_active", return_value=False),
patch( patch("roboco.services.notification.NotificationService", return_value=svc),
"roboco.services.notification.NotificationService", db_ctx,
return_value=svc, 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() svc.send_board_review_complete_notification.assert_not_awaited()
assert task_id not in orch._board_review_ceo_notified assert task_id not in orch._board_review_ceo_notified
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ceo_notify_failure_allows_retry() -> None: async def test_ceo_handoff_failure_allows_retry() -> None:
"""A notification failure clears the one-shot guard so a later tick retries.""" """A handoff failure clears the one-shot guard so a later tick retries."""
orch = _make_orch() orch = _make_orch()
task_id = str(uuid4()) task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id)) orch._board_dispatched.add(("product-owner", task_id))
@@ -248,16 +270,17 @@ async def test_ceo_notify_failure_allows_retry() -> None:
svc = AsyncMock() svc = AsyncMock()
svc.send_board_review_complete_notification.side_effect = RuntimeError("db down") svc.send_board_review_complete_notification.side_effect = RuntimeError("db down")
task_svc = AsyncMock()
db_ctx, task_ctx = _patch_handoff_db(task_svc)
with ( with (
patch.object(orch, "_is_agent_active", return_value=False), patch.object(orch, "_is_agent_active", return_value=False),
patch( patch("roboco.services.notification.NotificationService", return_value=svc),
"roboco.services.notification.NotificationService", db_ctx,
return_value=svc, 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 assert task_id not in orch._board_review_ceo_notified