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