feat(board): materialize program items as Main-PM roots, make reports actionable (#711)

Two coupled gaps in the Board Program output path.

Approved items were created unowned and in BACKLOG. Nothing dispatches
BACKLOG, and once activated a cell PM claimed the parentless task as a root,
where _cell_pm_complete resolves its merge target through
resolve_parent_branch — which for a parentless task falls through to the
project head rung. The result was a cell branch merging straight into the
trunk, bypassing the Main-PM root, the root->master PR and the CEO gate
(live: PRs #703 and #704 both targeted slave directly).

All eight materializers now create a PENDING, main-pm-assigned root with
team=Team.MAIN_PM, matching what approve_and_start does for an intake draft.
The team is load-bearing, not cosmetic: _next_hint_pr_fail,
_deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer
label all key on it, and a cell-teamed root drops the 'do NOT re-submit the
root' steer that exists because of PR #138's infinite pr_fail loop. The
item's own cell survives as a delegation hint in the description, which is
what the Main PM's briefing renders.

Periscope, Sentinel and Coroner produced artifacts with no way to act on
them — three panel surfaces carried explicit 'no approve/reject UI' comments
while each item already held a machine-readable suggested action. They now
have per-item approve and dismiss, modelled on the roadmap queue: idempotent
per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check
still fires, and every decision recorded through record_decision so it
reaches the next cycle's prompt. Approving materializes through the same
corrected Main-PM-owned path.

Target project resolves to each engine's own existing anchor — RoboCo's
project for Periscope and Sentinel, the incident's project for Coroner — and
fails with a clean invalid_state naming what is unresolvable rather than
guessing at a repo.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-26 20:01:24 +02:00
committed by GitHub
co-authored by Renn F
parent 66f0287d11
commit a7b970a3b2
48 changed files with 4412 additions and 377 deletions
+82 -8
View File
@@ -1,20 +1,31 @@
"""Coroner (Board Program) engine API — read-only Postmortems list.
"""Coroner (Board Program) engine API — the CEO reads filed postmortems and
approves/dismisses each one's process change.
Unlike Pest Control/Roadmap there is nothing here for the CEO to approve or
reject: a postmortem completes atomically the moment the Auditor calls
``propose_postmortem`` (spec §4). This route just lists what Coroner has
already found. CEO-only, mirroring every other Board Program surface.
A postmortem completes atomically at ``propose_postmortem`` time — the
EXPLORATION TASK has no per-item decision to wait on — but its single
process change still carries its own proposed/approved/rejected status the
CEO decides on afterward (unless kind="playbook", already routed into the
playbook queue). Unlike Periscope/Sentinel there is no item id: a postmortem
is one process change, not a list, so the action routes key on the task id
alone. CEO-only, mirroring every other Board Program surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.coroner import PostmortemResponse
from roboco.api.schemas.coroner import (
PostmortemResponse,
ProcessChangeActionResponse,
ProcessChangeRejectRequest,
)
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services.coroner_service import get_coroner_service
from roboco.services.task import get_task_service
if TYPE_CHECKING:
@@ -24,7 +35,7 @@ router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view the Coroner postmortems list")
require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
def _to_response(task: TaskTable) -> PostmortemResponse:
@@ -44,6 +55,9 @@ def _to_response(task: TaskTable) -> PostmortemResponse:
process_change_kind=process_change.get("kind"),
process_change_description=process_change.get("description"),
playbook_id=postmortem.get("playbook_id"),
process_change_status=process_change.get("status", "proposed"),
process_change_reject_reason=process_change.get("reject_reason"),
process_change_materialized_task_id=process_change.get("materialized_task_id"),
)
@@ -55,3 +69,63 @@ async def list_postmortems(
_require_ceo(agent)
tasks = await get_task_service(db).list_completed_coroner_postmortems()
return [_to_response(t) for t in tasks]
@router.post(
"/postmortems/{task_id}/process-change/approve",
response_model=ProcessChangeActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
async def approve_process_change(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> ProcessChangeActionResponse:
"""Materialize the postmortem's process change as a Main-PM-owned root
task (idempotent)."""
_require_ceo(agent)
result = await get_coroner_service(db).approve_process_change(
task_id, created_by=agent.agent_id
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such Coroner postmortem",
)
await db.commit()
return ProcessChangeActionResponse(
status=result.status,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
@router.post(
"/postmortems/{task_id}/process-change/reject",
response_model=ProcessChangeActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def reject_process_change(
task_id: UUID,
data: ProcessChangeRejectRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> ProcessChangeActionResponse:
"""Dismiss the postmortem's process change with a reason (idempotent)."""
_require_ceo(agent)
result = await get_coroner_service(db).reject_process_change(task_id, data.reason)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such Coroner postmortem",
)
await db.commit()
return ProcessChangeActionResponse(
status=result.status,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)