mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user