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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user