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
+204 -14
View File
@@ -1,6 +1,8 @@
"""EvidenceRepo: aggregates unread A2As, mentions, notifications, etc.
"""EvidenceRepo: aggregates unread A2As, mentions, notifications, and task/team
context for an agent's ``context_briefing`` (plus the task journal handoff).
Phase 1 stub returns empty lists; Phase 2+ wires real queries.
Each method is a single capped query over the live tables — they run on the
briefing-assembly path (per verb), so each stays cheap (LIMIT 10, indexed lookups).
"""
from __future__ import annotations
@@ -18,28 +20,216 @@ class EvidenceRepo:
self._db = db_session
async def list_unread_a2a(self, agent_id: UUID) -> list[dict[str, Any]]:
del agent_id
return []
"""Open A2A conversations with unread messages for this agent.
Conversations are keyed by agent slug (``agent_a``/``agent_b``) with a
per-side unread counter; surface the ones this agent has yet to read.
"""
from sqlalchemy import or_, select
from roboco.db.tables import A2AConversationTable, AgentTable
slug = await self._db.scalar(
select(AgentTable.slug).where(AgentTable.id == agent_id)
)
if slug is None:
return []
rows = (
(
await self._db.execute(
select(A2AConversationTable)
.where(
or_(
(A2AConversationTable.agent_a == slug)
& (A2AConversationTable.unread_by_a > 0),
(A2AConversationTable.agent_b == slug)
& (A2AConversationTable.unread_by_b > 0),
)
)
.order_by(A2AConversationTable.updated_at.desc())
.limit(10)
)
)
.scalars()
.all()
)
items: list[dict[str, Any]] = []
for c in rows:
is_a = c.agent_a == slug
items.append(
{
"conversation_id": str(c.id),
"from_agent": c.agent_b if is_a else c.agent_a,
"unread": c.unread_by_a if is_a else c.unread_by_b,
"topic": c.topic,
"task_id": str(c.task_id) if c.task_id else None,
}
)
return items
async def list_unread_mentions(self, agent_id: UUID) -> list[dict[str, Any]]:
del agent_id
return []
"""Recent channel messages that @mention this agent.
Messages carry no per-recipient read state, so this surfaces the most
recent mentions (capped); the briefing is rebuilt each turn.
"""
from sqlalchemy import select
from roboco.db.tables import MessageTable
result = await self._db.execute(
select(
MessageTable.id,
MessageTable.agent_id,
MessageTable.channel_id,
MessageTable.content,
MessageTable.task_id,
MessageTable.timestamp,
)
.where(MessageTable.mentions.contains([agent_id]))
.order_by(MessageTable.timestamp.desc())
.limit(10)
)
return [
{
"message_id": str(row.id),
"from_agent": str(row.agent_id),
"channel_id": str(row.channel_id) if row.channel_id else None,
"excerpt": (row.content or "")[:280],
"task_id": str(row.task_id) if row.task_id else None,
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
}
for row in result.all()
]
async def list_pending_notifications(self, agent_id: UUID) -> list[dict[str, Any]]:
del agent_id
return []
"""Unacknowledged, unexpired notifications addressed to this agent."""
from datetime import UTC, datetime
from sqlalchemy import or_, select
from roboco.db.tables import NotificationTable
now = datetime.now(UTC)
result = await self._db.execute(
select(
NotificationTable.id,
NotificationTable.type,
NotificationTable.priority,
NotificationTable.subject,
NotificationTable.body,
NotificationTable.from_agent,
NotificationTable.related_task_id,
NotificationTable.timestamp,
)
.where(NotificationTable.to_agents.contains([agent_id]))
.where(~NotificationTable.acked_by.contains([agent_id]))
.where(
or_(
NotificationTable.expires_at.is_(None),
NotificationTable.expires_at > now,
)
)
.order_by(NotificationTable.timestamp.desc())
.limit(10)
)
return [
{
"notification_id": str(row.id),
"type": str(row.type),
"priority": str(row.priority),
"subject": row.subject,
"body": row.body,
"from_agent": str(row.from_agent) if row.from_agent else None,
"task_id": str(row.related_task_id) if row.related_task_id else None,
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
}
for row in result.all()
]
async def task_metadata_gaps(self, task_id: UUID) -> list[str]:
del task_id
return []
"""Human-readable gaps in a task's metadata the owner should fill."""
from sqlalchemy import select
from roboco.db.tables import TaskTable
task = await self._db.scalar(select(TaskTable).where(TaskTable.id == task_id))
if task is None:
return []
gaps: list[str] = []
if not task.acceptance_criteria:
gaps.append("no acceptance criteria")
if not task.description:
gaps.append("no description")
return gaps
async def recent_team_activity(self, agent_id: UUID) -> list[dict[str, Any]]:
del agent_id
return []
"""Recently-updated tasks in this agent's team (lane awareness)."""
from sqlalchemy import func, select
from roboco.db.tables import AgentTable, TaskTable
team = await self._db.scalar(
select(AgentTable.team).where(AgentTable.id == agent_id)
)
if team is None:
return []
result = await self._db.execute(
select(
TaskTable.id,
TaskTable.title,
TaskTable.status,
TaskTable.assigned_to,
TaskTable.updated_at,
)
.where(TaskTable.team == team)
.order_by(func.coalesce(TaskTable.updated_at, TaskTable.created_at).desc())
.limit(10)
)
return [
{
"task_id": str(row.id),
"title": row.title,
"status": str(row.status),
"assigned_to": str(row.assigned_to) if row.assigned_to else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
for row in result.all()
]
async def blockers_in_lane(self, agent_id: UUID) -> list[dict[str, Any]]:
del agent_id
return []
"""Blocked tasks in this agent's team."""
from sqlalchemy import select
from roboco.db.tables import AgentTable, TaskTable
from roboco.models.base import TaskStatus
team = await self._db.scalar(
select(AgentTable.team).where(AgentTable.id == agent_id)
)
if team is None:
return []
result = await self._db.execute(
select(
TaskTable.id,
TaskTable.title,
TaskTable.assigned_to,
TaskTable.dependency_ids,
)
.where(TaskTable.team == team)
.where(TaskTable.status == TaskStatus.BLOCKED)
.order_by(TaskTable.updated_at.desc())
.limit(10)
)
return [
{
"task_id": str(row.id),
"title": row.title,
"assigned_to": str(row.assigned_to) if row.assigned_to else None,
"blocked_on": [str(d) for d in (row.dependency_ids or [])],
}
for row in result.all()
]
async def journal_highlights_for_task(self, task_id: UUID) -> list[dict[str, Any]]:
"""The task's upstream handoff: every author's decision / reflection /
+84 -11
View File
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import (
AgentTable,
JournalEntryTable,
JournalTable,
ProjectTable,
SessionTaskTable,
TaskTable,
@@ -34,6 +36,7 @@ from roboco.models.base import (
AgentRole,
AgentStatus,
BlockerResolverType,
JournalEntryType,
TaskNature,
TaskStatus,
TaskType,
@@ -42,6 +45,7 @@ from roboco.models.base import (
from roboco.models.permissions import AgentContext, TaskAction
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import (
BaseService,
NotFoundError,
@@ -3819,6 +3823,48 @@ class TaskService(BaseService):
await self.session.flush()
return True
async def _write_handoff_journal(
self, *, author_id: UUID, task_id: UUID, title: str, content: str
) -> None:
"""Record a handoff journal entry from ``author_id`` for ``task_id``.
Handoff-typed (``DECISION_LOG``) entries are the channel that actually
reaches a downstream worker: ``EvidenceRepo.journal_highlights_for_task``
serves them into the assignee's evidence/briefing. ``quick_context`` is
write-only by comparison. Adds rows to the session WITHOUT committing — the
caller owns the transaction boundary.
"""
# Best-effort: never let a journaling hiccup block the reject. In
# production the author (CEO) is a seeded agent; guard so a missing author
# (e.g. a minimal test DB) skips cleanly instead of FK-failing the flush.
author_exists = await self.session.scalar(
select(AgentTable.id).where(AgentTable.id == author_id)
)
if author_exists is None:
self.log.warning(
"Handoff journal skipped - author agent not found",
author_id=str(author_id),
)
return
result = await self.session.execute(
select(JournalTable).where(JournalTable.agent_id == author_id)
)
journal = result.scalar_one_or_none()
if journal is None:
journal = JournalTable(agent_id=author_id)
self.session.add(journal)
await self.session.flush()
self.session.add(
JournalEntryTable(
journal_id=journal.id,
type=JournalEntryType.DECISION_LOG,
title=title,
content=content,
task_id=task_id,
)
)
async def ceo_reject(
self,
task_id: UUID,
@@ -3862,20 +3908,47 @@ class TaskService(BaseService):
# Validate transition with CEO role requirement
self._validate_and_set_status(task, TaskStatus.NEEDS_REVISION, "ceo")
# Try to reassign to original developer
original_dev = extract_original_developer(task.quick_context)
if original_dev:
task.assigned_to = cast("Any", UUID(original_dev))
task.claimed_by = cast("Any", UUID(original_dev))
# Surface the CEO's required changes through the task journal — the one
# channel a downstream worker actually reads (evidence.journal_highlights
# serves handoff entries into the briefing). quick_context above is
# write-only, so the reason would otherwise never reach the reworker.
await self._write_handoff_journal(
author_id=UUID(AGENT_UUIDS["ceo"]),
task_id=task_id,
title="CEO change request",
content=reason,
)
# Route the rejected task to whoever should drive the rework.
reassigned_to: str | None
if task.project_id is None and task.product_id is not None:
# Coordination/integration root: the Main PM delegates the rework — a
# board/dev role cannot drive a coordination task.
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
task.team = Team.MAIN_PM
task.assigned_to = cast("Any", main_pm_id)
task.claimed_by = cast("Any", main_pm_id)
reassigned_to = str(main_pm_id)
self.log.info(
"Task reassigned to original developer after CEO rejection",
"Coordination task rejected by CEO - routed to Main PM",
task_id=str(task_id),
original_developer=original_dev,
)
else:
# Clear assignment so it can be claimed
task.assigned_to = None
task.claimed_by = None
original_dev = extract_original_developer(task.quick_context)
if original_dev:
task.assigned_to = cast("Any", UUID(original_dev))
task.claimed_by = cast("Any", UUID(original_dev))
self.log.info(
"Task reassigned to original developer after CEO rejection",
task_id=str(task_id),
original_developer=original_dev,
)
reassigned_to = original_dev
else:
# No tracked developer: leave for the pool to claim.
task.assigned_to = None
task.claimed_by = None
reassigned_to = None
await self.session.flush()
@@ -3883,7 +3956,7 @@ class TaskService(BaseService):
await self._emit_task_event(
EventType.TASK_CEO_REJECTED,
task_id,
{"reason": reason, "reassigned_to": original_dev},
{"reason": reason, "reassigned_to": reassigned_to},
)
self.log.info(