Fix: CEO reject conduit and main pm routing (#91)

* fix(tasks): deliver CEO change-requests to the reworker and route integration rejects to the Main PM

ceo_reject previously appended the CEO's required-changes only to quick_context
(never surfaced to an agent) and always reassigned to the original developer, so
the feedback never reached whoever reworked the task and a coordination/integration
root went to a developer instead of the Main PM.

Now ceo_reject records the reason as a DECISION_LOG handoff journal entry — the
channel evidence/journal_highlights already serves to the task's assignee — and
routes a coordination task (no project, has product) to team=main_pm + the Main PM
so it can delegate the rework. Leaf dev tasks still return to the original
developer. The journal write is best-effort (author-exists guard) and never blocks
a reject. Adds tests for the routing and the handoff-journal write.

* feat(gateway): wire the agent context_briefing receive-path

EvidenceRepo's agent-scoped methods were stubbed to return empty lists, so agents
never received notifications, A2A DMs, @mentions, recent team activity, or in-lane
blockers through their briefing — the system was effectively send-only. This
implements all five as single capped queries over the live tables (plus a light
task-metadata-gap check), mirroring the existing journal_highlights query. Each runs
on the per-verb briefing path, so each stays a single LIMIT-10 indexed lookup.

Unit tests cover the row mapping + empty paths; an integration suite exercises the
real SQL (array contains, the a2a slug filter, team+status) against Postgres.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-10 02:26:45 +02:00
committed by GitHub
co-authored by Renn F
parent 4dc2ce2867
commit 48556a032b
5 changed files with 666 additions and 87 deletions
@@ -0,0 +1,167 @@
"""EvidenceRepo real-DB query coverage.
Exercises the actual SQL (array ``contains``, the a2a slug ``or_``, team+status
filters) against a live Postgres — the part the mocked unit tests can't catch.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import (
A2AConversationTable,
AgentTable,
NotificationTable,
ProjectTable,
)
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
Complexity,
NotificationPriority,
NotificationType,
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.gateway.evidence_repo import EvidenceRepo
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def setup(db_session: AsyncSession) -> AsyncIterator[dict]:
agent = AgentTable(
id=uuid4(),
name="BE PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="EV-Proj",
slug=f"ev-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
yield {
"repo": EvidenceRepo(db_session),
"svc": TaskService(db_session),
"agent": agent,
"project_id": project.id,
"db": db_session,
}
@pytest.mark.asyncio
async def test_pending_notifications_returns_unacked_for_agent(setup: dict) -> None:
agent = setup["agent"]
setup["db"].add(
NotificationTable(
type=NotificationType.ALERT,
priority=NotificationPriority.HIGH,
from_agent=agent.id,
to_agents=[agent.id],
subject="CEO change request",
body="redo the API contract",
timestamp=datetime.now(UTC),
)
)
await setup["db"].flush()
out = await setup["repo"].list_pending_notifications(agent.id)
assert len(out) == 1
assert out[0]["subject"] == "CEO change request"
# An unrelated agent sees nothing (array membership filter works).
assert await setup["repo"].list_pending_notifications(uuid4()) == []
@pytest.mark.asyncio
async def test_unread_a2a_returns_conversations_with_unread(setup: dict) -> None:
agent = setup["agent"]
now = datetime.now(UTC)
seeded_unread = 2
setup["db"].add(
A2AConversationTable(
agent_a=agent.slug,
agent_b="main-pm",
unread_by_a=seeded_unread,
unread_by_b=0,
topic="rework",
created_at=now,
updated_at=now,
)
)
await setup["db"].flush()
out = await setup["repo"].list_unread_a2a(agent.id)
assert len(out) == 1
assert out[0]["from_agent"] == "main-pm"
assert out[0]["unread"] == seeded_unread
@pytest.mark.asyncio
async def test_blockers_and_recent_activity_scoped_to_team(setup: dict) -> None:
svc, agent = setup["svc"], setup["agent"]
task = await svc.create(
TaskCreateRequest(
title="blocked thing",
description="d",
acceptance_criteria=["ac"],
team=Team.BACKEND,
created_by=agent.id,
project_id=setup["project_id"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
)
)
task.status = TaskStatus.BLOCKED
await setup["db"].flush()
blockers = await setup["repo"].blockers_in_lane(agent.id)
assert any(b["task_id"] == str(task.id) for b in blockers)
recent = await setup["repo"].recent_team_activity(agent.id)
assert any(r["task_id"] == str(task.id) for r in recent)
@pytest.mark.asyncio
async def test_task_metadata_gaps_flags_missing(setup: dict) -> None:
svc, agent = setup["svc"], setup["agent"]
task = await svc.create(
TaskCreateRequest(
title="thin",
description="d",
acceptance_criteria=["ac"],
team=Team.BACKEND,
created_by=agent.id,
project_id=setup["project_id"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
)
)
task.acceptance_criteria = []
task.description = ""
await setup["db"].flush()
gaps = await setup["repo"].task_metadata_gaps(task.id)
assert "no acceptance criteria" in gaps
assert "no description" in gaps
@@ -9,23 +9,31 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable
from roboco.db.tables import (
AgentTable,
JournalEntryTable,
ProductTable,
ProjectTable,
)
from roboco.events import EventType
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
BlockerResolverType,
Complexity,
JournalEntryType,
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.task import TaskCreateRequest
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import NotFoundError
from roboco.services.task import SoftBlockInfo, TaskService
from sqlalchemy import select
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -577,6 +585,99 @@ async def test_ceo_reject_clears_assignment_when_no_original_dev(
assert rejected.assigned_to is None
@pytest.mark.asyncio
async def test_ceo_reject_routes_coordination_task_to_main_pm(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A rejected coordination/integration root goes to the Main PM to delegate,
not back to a developer."""
svc = task_setup["svc"]
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
if await db_session.get(AgentTable, main_pm_id) is None:
db_session.add(
AgentTable(
id=main_pm_id,
name="Main PM",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
)
product = ProductTable(
name="P", slug=f"p-{uuid4().hex[:8]}", created_by=task_setup["agent_id"]
)
db_session.add(product)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.project_id = None # coordination root: no project, has product
task.product_id = product.id
await db_session.flush()
rejected = await svc.ceo_reject(task.id, reason="redo the API contract")
assert rejected is not None
assert rejected.status == TaskStatus.NEEDS_REVISION
assert rejected.team == Team.MAIN_PM
assert rejected.assigned_to == main_pm_id
@pytest.mark.asyncio
async def test_ceo_reject_writes_handoff_journal(
task_setup: dict, db_session: AsyncSession
) -> None:
"""The CEO's reason is recorded as a DECISION_LOG journal entry on the task —
the channel that actually reaches the reworker (quick_context does not)."""
svc = task_setup["svc"]
ceo_id = UUID(AGENT_UUIDS["ceo"])
if await db_session.get(AgentTable, ceo_id) is None:
db_session.add(
AgentTable(
id=ceo_id,
name="CEO",
slug="ceo",
role=AgentRole.CEO,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="ceo",
capabilities=[],
permissions={},
metrics={},
)
)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.quick_context = f"original_developer:{task_setup['agent_id']}"
await db_session.flush()
reason = "AC9/AC10 totals must include cache tokens"
rejected = await svc.ceo_reject(task.id, reason=reason)
assert rejected is not None
entries = (
(
await db_session.execute(
select(JournalEntryTable).where(
JournalEntryTable.task_id == task.id,
JournalEntryTable.type == JournalEntryType.DECISION_LOG,
)
)
)
.scalars()
.all()
)
assert any(reason in (e.content or "") for e in entries)
# ---------------------------------------------------------------------------
# escalate_to_ceo - all error branches
# ---------------------------------------------------------------------------