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
+36 -2
View File
@@ -25,6 +25,7 @@ from sqlalchemy import update
_SEED_GIT_URL = "https://example.com/backend-svc-spackle.git"
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
@@ -192,9 +223,10 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/spackle/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
@pytest.mark.asyncio