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