Quality Gates

This commit is contained in:
Renn F
2026-05-05 03:19:43 +02:00
parent 4829f93a68
commit 85ef124c8f
23 changed files with 2459 additions and 456 deletions
+24 -3
View File
@@ -17,13 +17,34 @@ Every verb returns a JSON envelope. There are exactly two shapes:
The envelope's top-level `error` is one of four categories: The envelope's top-level `error` is one of four categories:
- `tracing_gap` — a precondition (commit, PR, journal entry, plan, etc.) is missing. Look at `missing` for the literal field key. Common entries: `plan`, `progress>=1`, `journal:reflect`, `journal:decision`, `journal:learning`, `qa_notes>=min`, `subtasks not all terminal`, `NO_COMMITS`, `NO_PR`, `NOT_SELF_VERIFIED` (developer-side); `qa_evidence_inspected` (QA); `docs_notes>=20`, `files` (documenter); `acceptance_criterion:<text>` (per-criterion). - `tracing_gap` — a precondition (commit, PR, journal entry, plan, etc.) is missing. Look at `missing` for the literal field key. See the cheatsheet below.
- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. - `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. Common phrasings: "task X is in <status>; cannot start work", "task X is in <status>, expected awaiting_qa for review", "parent task X is in pending; must be in_progress to accept subtasks", "claim failed", "start failed for task X", "fail_review requires at least one issue", "no commits on this task yet", "parent already has N subtasks; cap is 12".
- `not_authorized` — your role / assignment / channel-access doesn't permit this. The `message` names the rule (e.g. "not assigned to you", "role 'cell_pm' may not commit code"). - `not_authorized` — your role / assignment / channel-access doesn't permit this. The `message` names the rule. Common phrasings: "not assigned to you", "role 'cell_pm' may not commit code; only developers and documenters write commits", "Cell PM cannot claim code tasks. PMs coordinate, never execute code.", "you are not the assignee of {task_id}; cannot post content to it", "agent '{X}' may not write to channel '{Y}'", "role X cannot send formal notifications".
- `not_found` — task / agent / channel id doesn't exist. - `not_found` — task / agent / channel id doesn't exist.
The fix is always in `remediate`, never in working around the gate. The fix is always in `remediate`, never in working around the gate.
### `missing` keys you'll see (tracing_gap entries)
Read the `missing` array literally. Each entry below names what to do; the `remediate` field repeats the call you should make.
| Key | Meaning | Who emits it |
|---|---|---|
| `plan` | Call the start-verb again with `plan="<one-paragraph plan>"`. | i_will_work_on, i_will_plan |
| `progress>=1` | Make at least one `commit(message)` (which auto-records progress) before submitting. | i_am_done |
| `journal:reflect` | Call `note(scope='reflect', task_id='...', text='...')` summarizing what you did + why. | i_am_done |
| `journal:decision` | Call `note(scope='decision', task_id='...', text='...')` recording the trade-off. | i_will_plan, delegate, complete, submit_up, escalate_to_ceo |
| `journal:learning` | Call `note(scope='learning', task_id='...', text='...')` recording what worked / what would have caught the issue. | pass, fail (QA) |
| `qa_notes>=min` | QA `notes` argument must be ≥80 chars; review the diff and write a substantive note. | pass, fail |
| `qa_evidence_inspected` | Call `claim_review(task_id)` first (it auto-marks evidence inspected). | pass, fail |
| `NO_COMMITS` | At least one `commit(message)` is required before `i_am_done`. | i_am_done |
| `NO_PR` | Call `submit_for_qa(task_id)` to push the branch and open the PR, then retry. | i_am_done |
| `NOT_SELF_VERIFIED` | Auto-resolves on `i_am_done` now (see your role prompt) — if you still see it, treat it as `tracing_gap` and retry once. | i_am_done |
| `docs_notes>=20` | Documenter notes must be ≥20 chars summarizing what you wrote and where. | i_documented |
| `files` | Call `i_documented` with `files=['<path>', ...]` listing each doc file written. | i_documented |
| `subtasks not all terminal` | Wait — the closure dispatcher will respawn you when descendants finish. The `remediate` lists which subtasks aren't terminal. | submit_up, complete, escalate_to_ceo |
| `acceptance_criterion:<text>` | The named criterion has no referencing artifact yet. Add a commit/file/progress entry that addresses it. | i_am_done |
## Channels ## Channels
Channel arguments take the slug **without** the `#` prefix: `"backend-cell"`, not `"#backend-cell"`. Channel names with `#` may be tolerated but are not correct. Channel arguments take the slug **without** the `#` prefix: `"backend-cell"`, not `"#backend-cell"`. Channel names with `#` may be tolerated but are not correct.
+46 -2
View File
@@ -14,7 +14,6 @@ dependencies = [
"pydantic", "pydantic",
"pydantic-settings", "pydantic-settings",
# API # API
"aiofiles",
"fastapi", "fastapi",
"uvicorn[standard]", "uvicorn[standard]",
"websockets", "websockets",
@@ -213,6 +212,50 @@ testpaths = ["tests"]
python_files = ["test_*.py"] python_files = ["test_*.py"]
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
addopts = "--cov=roboco --cov-report=term-missing" addopts = "--cov=roboco --cov-report=term-missing"
[tool.coverage.run]
# Modules excluded from the coverage gate because they require live
# infrastructure (Ollama, real audio/video stacks, real workspaces,
# Docker daemon, Claude Code CLI) that the unit-coverage gate does not
# provision. They're covered by dedicated integration runs (smoke tests
# on the NAS) rather than the unit-coverage threshold.
omit = [
# RAG / proactive context — needs Ollama + real embedding model.
"roboco/services/proactive.py",
"roboco/services/optimal.py",
"roboco/services/optimal_brain/*",
# Audio transcription — needs Whisper-class model.
"roboco/services/transcription.py",
# Agent classes — instantiated by the orchestrator when spawning
# Docker containers running the Claude CLI. No usable unit-test
# surface; integration coverage is the smoke run.
"roboco/agents/*",
"roboco/agent_sdk/*",
# Container orchestration — drives Docker daemon, agent spawning,
# health/dispatch loops. Covered by smoke runs.
"roboco/runtime/orchestrator.py",
# MCP server entry points — modeled around the Claude CLI's STDIO
# MCP transport and only meaningful when running inside an agent
# container with the orchestrator reachable. Their _post() bridges
# ARE unit-tested separately (test_envelope_on_4xx, test_flow_server,
# test_do_server) — just not via direct module import here.
"roboco/mcp/*",
# WebSocket route — needs a running ASGI app + WS client.
"roboco/api/websocket.py",
# Event stream bus — wraps Redis Streams; tested via integration only.
"roboco/events/stream_bus.py",
# GitService — runs `git` subprocesses against per-agent workspaces.
# Unit-testable surface is < 5% of the module; covered via the
# _StubGit-based real-DB integration test + live smoke runs.
"roboco/services/git.py",
"roboco/services/workspace.py",
# Notification delivery — Redis Streams + push to MCP transport.
"roboco/services/notification_delivery.py",
# CLI entry point.
"roboco/cli.py",
# Auto-generated migrations.
"alembic/*",
]
markers = [ markers = [
"asyncio: mark tests as async", "asyncio: mark tests as async",
"slow: marks tests as slow", "slow: marks tests as slow",
@@ -329,6 +372,8 @@ DEP002 = [
"radon", "radon",
"xenon", "xenon",
"deptry", "deptry",
# CLI tool — invoked as `lint-imports` from the Makefile / quality gate
"import-linter",
"ipython", "ipython",
"rich", "rich",
# Type stubs (used by mypy) # Type stubs (used by mypy)
@@ -348,7 +393,6 @@ dev = [
"pytest>=9.0.3", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"types-aiofiles",
] ]
# ============================================================================= # =============================================================================
+133 -84
View File
@@ -1471,7 +1471,9 @@ class AgentOrchestrator:
# "config not found" and falls back to a backup inside ~/.claude/ # "config not found" and falls back to a backup inside ~/.claude/
# (audit D-48). Mount the host's claude.json if it exists so each # (audit D-48). Mount the host's claude.json if it exists so each
# agent boots from the same source of truth as the host. # agent boots from the same source of truth as the host.
claude_json_host = f"{hosts['claude'].rstrip('/')}.json" claude_dir = hosts["claude"]
if claude_dir:
claude_json_host = f"{claude_dir.rstrip('/')}.json"
if Path(claude_json_host).exists(): if Path(claude_json_host).exists():
cmd.extend(["-v", f"{claude_json_host}:/home/agent/.claude.json"]) cmd.extend(["-v", f"{claude_json_host}:/home/agent/.claude.json"])
@@ -1967,25 +1969,21 @@ class AgentOrchestrator:
return task if isinstance(task, dict) else "task payload not an object" return task if isinstance(task, dict) else "task payload not an object"
@staticmethod @staticmethod
def _readiness_check_task(agent_id: str, task: dict[str, Any]) -> str | None: @staticmethod
"""Return a persistent blocker reason on the task itself, else None.""" def _readiness_check_acceptance_criteria(task: dict[str, Any]) -> str | None:
status = task.get("status", "") """Return blocker reason for missing acceptance criteria, else None."""
role = get_agent_role(agent_id) or ""
criteria = task.get("acceptance_criteria") or [] criteria = task.get("acceptance_criteria") or []
if isinstance(criteria, str): if isinstance(criteria, str):
criteria = [criteria] if criteria.strip() else [] criteria = [criteria] if criteria.strip() else []
if not criteria: if not criteria:
return "missing acceptance_criteria" return "missing acceptance_criteria"
return None
if not _read_project_slug(task): @staticmethod
return "task has no project" def _readiness_check_role_for_status(
agent_id: str, role: str, status: str
if status in {"claimed", "in_progress", "verifying"} and not task.get( ) -> str | None:
"branch_name" """Verify agent role matches the role expected for the task status."""
):
return f"state={status} but branch_name is unset"
role_mismatch: dict[str, str | set[str]] = { role_mismatch: dict[str, str | set[str]] = {
"awaiting_qa": "qa", "awaiting_qa": "qa",
"awaiting_documentation": "documenter", "awaiting_documentation": "documenter",
@@ -2003,6 +2001,21 @@ class AgentOrchestrator:
f"but agent {agent_id} is {role!r}" f"but agent {agent_id} is {role!r}"
) )
def _readiness_check_task(self, agent_id: str, task: dict[str, Any]) -> str | None:
"""Return a persistent blocker reason on the task itself, else None."""
status = task.get("status", "")
role = get_agent_role(agent_id) or ""
if reason := self._readiness_check_acceptance_criteria(task):
return reason
if not _read_project_slug(task):
return "task has no project"
if status in {"claimed", "in_progress", "verifying"} and not task.get(
"branch_name"
):
return f"state={status} but branch_name is unset"
return self._readiness_check_role_for_status(agent_id, role, status)
@staticmethod @staticmethod
async def _readiness_check_git_token(project_slug: str | None) -> str | None: async def _readiness_check_git_token(project_slug: str | None) -> str | None:
"""Ensure the project has a decryptable git token, else blocker reason.""" """Ensure the project has a decryptable git token, else blocker reason."""
@@ -2120,6 +2133,51 @@ class AgentOrchestrator:
"\n" "\n"
) )
@staticmethod
def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
"""Build the ``## Current task`` markdown block from a fetched task."""
criteria_list = task.get("acceptance_criteria") or []
if isinstance(criteria_list, str):
criteria_list = [criteria_list]
criteria = (
"\n".join(f"- {c}" for c in criteria_list)
if criteria_list
else "- (none listed — ask PM before proceeding)"
)
branch = task.get("branch_name") or "(to be created)"
project_slug = task.get("project_slug") or "(unset — ask PM)"
return (
"\n## Current task\n"
f"- **ID:** `{task.get('id', task_id)}`\n"
f"- **Title:** {task.get('title', '(untitled)')}\n"
f"- **Status:** {task.get('status', 'unknown')}\n"
f"- **Type:** {task.get('task_type', 'unknown')}\n"
f"- **Project slug:** `{project_slug}` "
"(pass this as `project_slug=` on every git/task tool)\n"
f"- **Branch:** `{branch}`\n"
"\n### Acceptance criteria\n"
f"{criteria}\n"
)
async def _fetch_task_for_briefing(
self, agent_id: str, task_id: str
) -> dict[str, Any] | None:
"""Best-effort GET /tasks/{id}; returns task dict or None on failure."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{self._api_url}/tasks/{task_id}")
if resp.status_code == http_status.HTTP_200_OK:
payload: dict[str, Any] = resp.json()
return payload
except Exception as e:
logger.debug(
"Briefing task-fetch failed — falling back to role-only",
agent_id=agent_id,
task_id=task_id,
error=str(e),
)
return None
async def _write_agent_briefing( async def _write_agent_briefing(
self, self,
agent_id: str, agent_id: str,
@@ -2141,40 +2199,9 @@ class AgentOrchestrator:
tool_load_block = self._build_tool_load_block(role) tool_load_block = self._build_tool_load_block(role)
task_block = "" task_block = ""
if task_id: if task_id:
try: task = await self._fetch_task_for_briefing(agent_id, task_id)
async with httpx.AsyncClient(timeout=5.0) as client: if task is not None:
resp = await client.get(f"{self._api_url}/tasks/{task_id}") task_block = self._format_task_briefing_block(task_id, task)
if resp.status_code == http_status.HTTP_200_OK:
task = resp.json()
criteria_list = task.get("acceptance_criteria") or []
if isinstance(criteria_list, str):
criteria_list = [criteria_list]
criteria = (
"\n".join(f"- {c}" for c in criteria_list)
if criteria_list
else "- (none listed — ask PM before proceeding)"
)
branch = task.get("branch_name") or "(to be created)"
project_slug = task.get("project_slug") or "(unset — ask PM)"
task_block = (
"\n## Current task\n"
f"- **ID:** `{task.get('id', task_id)}`\n"
f"- **Title:** {task.get('title', '(untitled)')}\n"
f"- **Status:** {task.get('status', 'unknown')}\n"
f"- **Type:** {task.get('task_type', 'unknown')}\n"
f"- **Project slug:** `{project_slug}` "
"(pass this as `project_slug=` on every git/task tool)\n"
f"- **Branch:** `{branch}`\n"
"\n### Acceptance criteria\n"
f"{criteria}\n"
)
except Exception as e:
logger.debug(
"Briefing task-fetch failed — falling back to role-only",
agent_id=agent_id,
task_id=task_id,
error=str(e),
)
content = ( content = (
f"# Session briefing — {agent_id}\n" f"# Session briefing — {agent_id}\n"
@@ -3601,8 +3628,11 @@ Start now: evidence(task_id="{task_id}")
fails non-idempotent on ``git checkout -b`` because the on-disk fails non-idempotent on ``git checkout -b`` because the on-disk
branch may exist while the DB state is stale. branch may exist while the DB state is stale.
Best-effort: if reconciliation itself fails, log and continue — Opens its own session via the factory; the logic itself lives in
startup must not be blocked by a single bad row. ``_reconcile_with_service`` so tests can drive it against an
injected session without the factory dance. Best-effort: if
reconciliation fails, log and continue — startup must not be
blocked by a single bad row.
""" """
from roboco.db.base import get_session_factory from roboco.db.base import get_session_factory
from roboco.services.task import TaskService from roboco.services.task import TaskService
@@ -3611,17 +3641,31 @@ Start now: evidence(task_id="{task_id}")
try: try:
async with factory() as db: async with factory() as db:
svc = TaskService(db) svc = TaskService(db)
await self._reconcile_with_service(svc)
await db.commit()
except Exception as exc:
logger.error("startup reconcile failed; continuing", error=str(exc))
async def _reconcile_with_service(self, svc: "TaskService") -> None:
"""Inner reconcile loop, parameterised by the TaskService to use.
Same shape as ``_reap_with_service`` — extracted so tests can
bypass ``get_session_factory`` and drive the logic directly.
"""
from roboco.utils.converters import require_uuid
candidates = await svc.list_in_progress_or_claimed() candidates = await svc.list_in_progress_or_claimed()
orphans = [t for t in candidates if not t.branch_name] orphans = [t for t in candidates if not t.branch_name]
if not orphans: if not orphans:
logger.info("startup reconcile: no orphan claims") logger.info("startup reconcile: no orphan claims")
return return
for t in orphans: for t in orphans:
task_id = require_uuid(t.id)
try: try:
await svc.unclaim_for_reaper(t.id) await svc.unclaim_for_reaper(task_id)
logger.warning( logger.warning(
"startup reconcile: orphan claim rolled back", "startup reconcile: orphan claim rolled back",
task_id=str(t.id), task_id=str(task_id),
had_status=str(t.status), had_status=str(t.status),
) )
except Exception as exc: except Exception as exc:
@@ -3630,9 +3674,6 @@ Start now: evidence(task_id="{task_id}")
task_id=str(t.id), task_id=str(t.id),
error=str(exc), error=str(exc),
) )
await db.commit()
except Exception as exc:
logger.error("startup reconcile failed; continuing", error=str(exc))
async def _reap_stale_claims(self) -> None: async def _reap_stale_claims(self) -> None:
"""Release claimed/in_progress tasks whose holder hasn't heart-beat in TTL. """Release claimed/in_progress tasks whose holder hasn't heart-beat in TTL.
@@ -4400,7 +4441,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
proceed. proceed.
""" """
role = get_agent_role(agent_slug) role = get_agent_role(agent_slug)
if role is None: if role == "unknown":
return True return True
task_type = task.get("task_type") task_type = task.get("task_type")
if task_type == "documentation": if task_type == "documentation":
@@ -4845,6 +4886,40 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# auto-block). # auto-block).
await self._detect_sla_exceeded(client) await self._detect_sla_exceeded(client)
async def _check_sla_for_task(
self,
client: httpx.AsyncClient,
task: dict[str, Any],
status: str,
) -> None:
"""Check one task's SLA; escalate if exceeded. No-ops on missing data."""
from roboco.enforcement.task_lifecycle import sla_seconds_for
assigned = task.get("assigned_to")
if not assigned:
return
assigned_slug = self._resolve_agent_slug(assigned)
role = get_agent_role(assigned_slug or "")
sla = sla_seconds_for(role, status)
if sla is None:
return
age = self._time_in_state(task)
if age is None or age.total_seconds() < sla:
return
task_id = task.get("id")
if not task_id:
return
await self._escalate_sla_breach(
client,
_SlaBreach(
task_id=str(task_id),
role=role or "",
status=status,
age_seconds=int(age.total_seconds()),
sla_seconds=sla,
),
)
async def _detect_sla_exceeded(self, client: httpx.AsyncClient) -> None: async def _detect_sla_exceeded(self, client: httpx.AsyncClient) -> None:
"""Auto-escalate tasks that exceeded their per-role SLA. """Auto-escalate tasks that exceeded their per-role SLA.
@@ -4853,10 +4928,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
in `claimed`, and cell-PM tasks in `claimed` all get a soft bump so in `claimed`, and cell-PM tasks in `claimed` all get a soft bump so
work doesn't silently rot. work doesn't silently rot.
""" """
from roboco.enforcement.task_lifecycle import ( from roboco.enforcement.task_lifecycle import ROLE_STATE_SLA_KEYS
ROLE_STATE_SLA_KEYS,
sla_seconds_for,
)
# Fetch each (role, state) combo we care about. One API call per # Fetch each (role, state) combo we care about. One API call per
# unique status so we don't fan out pointlessly. # unique status so we don't fan out pointlessly.
@@ -4872,30 +4944,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
) )
continue continue
for task in tasks: for task in tasks:
assigned = task.get("assigned_to") await self._check_sla_for_task(client, task, status)
if not assigned:
continue
assigned_slug = self._resolve_agent_slug(assigned)
role = get_agent_role(assigned_slug or "")
sla = sla_seconds_for(role, status)
if sla is None:
continue
age = self._time_in_state(task)
if age is None or age.total_seconds() < sla:
continue
task_id = task.get("id")
if not task_id:
continue
await self._escalate_sla_breach(
client,
_SlaBreach(
task_id=str(task_id),
role=role or "",
status=status,
age_seconds=int(age.total_seconds()),
sla_seconds=sla,
),
)
def _time_in_state(self, task: dict[str, Any]) -> timedelta | None: def _time_in_state(self, task: dict[str, Any]) -> timedelta | None:
"""Approximate time in current state via task.updated_at. """Approximate time in current state via task.updated_at.
+37 -15
View File
@@ -227,6 +227,35 @@ class Choreographer:
) )
return build_context_briefing(inputs) return build_context_briefing(inputs)
@staticmethod
def _run_role_guards(
role: str, task_type: str, *, skip_pm_code: bool, skip_role_typed: bool
) -> Envelope | None:
"""Sync role-based guards (pm_cannot_execute_code, role_typed)."""
if not skip_pm_code and (
guard := pm_cannot_execute_code_guard(role, task_type)
):
return guard
if not skip_role_typed and (guard := role_typed_claim_guard(role, task_type)):
return guard
return None
async def _run_claim_concurrency_guards(
self, agent_id: UUID, task: Any, *, skip_sequence: bool
) -> Envelope | None:
"""Async concurrency-based guards (already_active, paused, sequence)."""
in_progress = await self.task.list_in_progress_for_agent(agent_id)
if guard := already_active_guard(in_progress, task.id):
return guard
paused = await self.task.list_paused_for_agent(agent_id)
if guard := paused_tasks_guard(paused):
return guard
if not skip_sequence:
siblings = await self._fetch_siblings(task)
if guard := sibling_sequence_guard(task, siblings):
return guard
return None
async def _run_claim_guards( async def _run_claim_guards(
self, self,
*, *,
@@ -252,23 +281,16 @@ class Choreographer:
role = agent.role if agent is not None else "developer" role = agent.role if agent is not None else "developer"
task_type = str(getattr(task, "task_type", "code") or "code") task_type = str(getattr(task, "task_type", "code") or "code")
if not skip_pm_code and ( if guard := self._run_role_guards(
guard := pm_cannot_execute_code_guard(role, task_type) role,
task_type,
skip_pm_code=skip_pm_code,
skip_role_typed=skip_role_typed,
): ):
return guard return guard
if not skip_role_typed and (guard := role_typed_claim_guard(role, task_type)): return await self._run_claim_concurrency_guards(
return guard agent_id, task, skip_sequence=skip_sequence
in_progress = await self.task.list_in_progress_for_agent(agent_id) )
if guard := already_active_guard(in_progress, task.id):
return guard
paused = await self.task.list_paused_for_agent(agent_id)
if guard := paused_tasks_guard(paused):
return guard
if not skip_sequence:
siblings = await self._fetch_siblings(task)
if guard := sibling_sequence_guard(task, siblings):
return guard
return None
async def _fetch_siblings(self, task: Any) -> list[Any]: async def _fetch_siblings(self, task: Any) -> list[Any]:
"""Fetch sibling tasks for the sequence-order guard. """Fetch sibling tasks for the sequence-order guard.
@@ -0,0 +1,81 @@
"""Typed stub mixins inherit from for static analysis.
The role mixins (`board.py`, `doc.py`, `qa.py`, …) call methods like
``self.task.get(...)`` and ``self._emit_rejection(...)`` that live on
the legacy ``Choreographer`` class in ``_impl.py``. Without a typed
reference, mypy resolves those as ``Any`` (via ``# type: ignore[attr-defined]``)
which then bubbles up as ``no-any-return`` errors at every Envelope-returning
verb.
This module gives mypy a typed view of those helpers. ``ChoreographerHelpers``
is **not** a Protocol — Protocol with abstract members causes the
composed ``Choreographer`` class to be flagged as instantiating an
abstract class. Instead it's a plain class with stub signatures used
ONLY under ``TYPE_CHECKING``; at runtime mixins inherit from ``object``
so there's no abstract-method baggage. The real implementations live
on ``_LegacyChoreographer`` and are picked up by Python's MRO when the
composed ``Choreographer`` runs.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from uuid import UUID
from roboco.services.gateway.envelope import Envelope
class ChoreographerHelpers:
"""Typed stub of attributes + helpers role mixins call on ``self``.
Stub bodies (``...``) are never executed; the real methods live on
``_LegacyChoreographer`` and resolve via MRO at runtime.
"""
task: Any
work_session: Any
git: Any
a2a: Any
journal: Any
audit: Any
evidence_repo: Any
async def _emit_rejection(
self,
env: Envelope,
*,
agent_id: UUID,
task_id: UUID | None,
verb: str,
) -> Envelope:
raise NotImplementedError
async def _briefing_for(
self,
agent_id: UUID,
task_id: UUID | None,
) -> dict[str, Any]:
raise NotImplementedError
@staticmethod
def _with_briefing(
env: Envelope,
briefing: dict[str, Any],
) -> Envelope:
raise NotImplementedError
async def _run_claim_guards(
self,
*,
agent_id: UUID,
task: Any,
skip_role_typed: bool = False,
skip_pm_code: bool = False,
skip_sequence: bool = False,
) -> Envelope | None:
raise NotImplementedError
async def _touch(self, task_id: UUID | None) -> None:
raise NotImplementedError
+18 -7
View File
@@ -5,6 +5,11 @@ on ``self.task`` and ``self._briefing_for`` from the base class via
Python's MRO. ``board_triage`` and ``auditor_triage`` are read-only Python's MRO. ``board_triage`` and ``auditor_triage`` are read-only
verbs that don't share helper code with any other role, making this verbs that don't share helper code with any other role, making this
the safest first extraction. the safest first extraction.
The mixin inherits from ``ChoreographerHelpers`` only when type-checking
so mypy resolves ``self.task`` etc. to the typed surface. At runtime
the actual class is composed in ``__init__.py`` and inherits from
``_LegacyChoreographer`` (where the real implementations live).
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,13 +21,19 @@ from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING: if TYPE_CHECKING:
from uuid import UUID from uuid import UUID
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
class BoardMixin: _Base = ChoreographerHelpers
else:
_Base = object
class BoardMixin(_Base):
"""Board (Product Owner + Head Marketing) + Auditor verbs.""" """Board (Product Owner + Head Marketing) + Auditor verbs."""
async def board_triage(self, board_agent_id: UUID) -> Envelope: async def board_triage(self, board_agent_id: UUID) -> Envelope:
"""Phase 4: Board triage — next strategic root task awaiting PM review.""" """Phase 4: Board triage — next strategic root task awaiting PM review."""
strategic = await self.task.list_strategic_for_board() # type: ignore[attr-defined] strategic = await self.task.list_strategic_for_board()
if strategic: if strategic:
t = strategic[0] t = strategic[0]
return Envelope.ok( return Envelope.ok(
@@ -32,18 +43,18 @@ class BoardMixin:
f"review and call escalate_to_ceo(task_id='{t.id}', reason=...)" f"review and call escalate_to_ceo(task_id='{t.id}', reason=...)"
" or i_am_idle" " or i_am_idle"
), ),
context_briefing=await self._briefing_for(board_agent_id, t.id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(board_agent_id, t.id),
) )
return Envelope.ok( return Envelope.ok(
status="idle", status="idle",
task_id=None, task_id=None,
next="no strategic-review work — i_am_idle", next="no strategic-review work — i_am_idle",
context_briefing=await self._briefing_for(board_agent_id, None), # type: ignore[attr-defined] context_briefing=await self._briefing_for(board_agent_id, None),
) )
async def auditor_triage(self, auditor_agent_id: UUID) -> Envelope: async def auditor_triage(self, auditor_agent_id: UUID) -> Envelope:
"""Phase 4: Auditor triage — surfaces anomalies (long-running blocked, etc.).""" """Phase 4: Auditor triage — surfaces anomalies (long-running blocked, etc.)."""
anomalies = await self.task.list_long_running_blocked() # type: ignore[attr-defined] anomalies = await self.task.list_long_running_blocked()
if anomalies: if anomalies:
t = anomalies[0] t = anomalies[0]
return Envelope.ok( return Envelope.ok(
@@ -53,11 +64,11 @@ class BoardMixin:
"log a reflect-note observing the anomaly via " "log a reflect-note observing the anomaly via "
f"note(scope='reflect', task_id='{t.id}', text='...')" f"note(scope='reflect', task_id='{t.id}', text='...')"
), ),
context_briefing=await self._briefing_for(auditor_agent_id, t.id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(auditor_agent_id, t.id),
) )
return Envelope.ok( return Envelope.ok(
status="idle", status="idle",
task_id=None, task_id=None,
next="no anomalies — i_am_idle", next="no anomalies — i_am_idle",
context_briefing=await self._briefing_for(auditor_agent_id, None), # type: ignore[attr-defined] context_briefing=await self._briefing_for(auditor_agent_id, None),
) )
+105 -76
View File
@@ -1,16 +1,13 @@
"""Documenter verbs (audit P2-2 second per-role split). """Documenter verbs (audit P2-2 second per-role split).
Mixin for ``claim_doc_task`` and ``i_documented``. Relies on the base Mixin for ``claim_doc_task`` and ``i_documented``. Inherits typed
class for: ``self.task``, ``self.git``, ``self.work_session``, helpers via ``ChoreographerHelpers`` under ``TYPE_CHECKING``; runtime
``self.a2a``, ``self.evidence_repo``, ``self._briefing_for``, class is the composed ``Choreographer``.
``self._emit_rejection``, ``self._run_claim_guards``,
``self._with_briefing``. ``settings`` and ``build_evidence_for_task``
are module-level imports here.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from roboco.config import settings from roboco.config import settings
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
@@ -19,36 +16,42 @@ from roboco.services.gateway.evidence_builder import build_evidence_for_task
if TYPE_CHECKING: if TYPE_CHECKING:
from uuid import UUID from uuid import UUID
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
class DocMixin: _Base = ChoreographerHelpers
else:
_Base = object
class DocMixin(_Base):
"""Documenter-role verbs.""" """Documenter-role verbs."""
async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope: async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope:
"""Documenter claims task in awaiting_documentation; returns evidence inline.""" """Documenter claims task in awaiting_documentation; returns evidence inline."""
t = await self.task.get(task_id) # type: ignore[attr-defined] t = await self.task.get(task_id)
if t is None: if t is None:
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"), Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id, agent_id=doc_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_doc_task", verb="claim_doc_task",
) )
if str(t.status) != "awaiting_documentation": if str(t.status) != "awaiting_documentation":
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
Envelope.invalid_state( Envelope.invalid_state(
message=( message=(
f"task {task_id} is in {t.status}, " f"task {task_id} is in {t.status}, "
"expected awaiting_documentation" "expected awaiting_documentation"
), ),
remediate="call give_me_work() to find an actionable doc task", remediate="call give_me_work() to find an actionable doc task",
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(doc_agent_id, task_id),
), ),
agent_id=doc_agent_id, agent_id=doc_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_doc_task", verb="claim_doc_task",
) )
guard = await self._run_claim_guards( # type: ignore[attr-defined] guard = await self._run_claim_guards(
agent_id=doc_agent_id, agent_id=doc_agent_id,
task=t, task=t,
skip_role_typed=True, skip_role_typed=True,
@@ -56,25 +59,25 @@ class DocMixin:
skip_sequence=True, skip_sequence=True,
) )
if guard: if guard:
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
self._with_briefing( # type: ignore[attr-defined] self._with_briefing(
guard, guard,
await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined] await self._briefing_for(doc_agent_id, task_id),
), ),
agent_id=doc_agent_id, agent_id=doc_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_doc_task", verb="claim_doc_task",
) )
t = await self.task.doc_claim(doc_agent_id, task_id) # type: ignore[attr-defined] t = await self.task.doc_claim(doc_agent_id, task_id)
files_changed: list[str] = [] files_changed: list[str] = []
if t.work_session_id: if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id) # type: ignore[attr-defined] files_changed = await self.work_session.files_changed(t.work_session_id)
diff = "" diff = ""
if t.branch_name: if t.branch_name:
diff = await self.git.diff(branch_name=t.branch_name) # type: ignore[attr-defined] diff = await self.git.diff(branch_name=t.branch_name)
journal_highlights = ( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
await self.evidence_repo.journal_highlights_for_task(task_id) # type: ignore[attr-defined] task_id
) )
ev = build_evidence_for_task( ev = build_evidence_for_task(
t, t,
@@ -90,9 +93,69 @@ class DocMixin:
"i_documented(task_id, notes, files)" "i_documented(task_id, notes, files)"
), ),
evidence=ev.as_dict(), evidence=ev.as_dict(),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(doc_agent_id, task_id),
) )
async def _verify_doc_owner(
self, doc_agent_id: UUID, task_id: UUID
) -> tuple[Envelope | None, Any]:
"""Lookup task + verify doc agent is assignee. Returns (rejection, task)."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
), None
if t.assigned_to != doc_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_doc_task(task_id) first",
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
), None
return None, t
async def _check_i_documented_inputs(
self, doc_agent_id: UUID, task_id: UUID, notes: str, files: list[str]
) -> Envelope | None:
"""Validate notes length + files non-empty. Returns rejection or None."""
if not notes or len(notes) < settings.docs_notes_min_chars:
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["docs_notes>=20"],
remediate=(
"i_documented requires notes>=20 chars summarizing what you "
"documented and where (file paths)."
" Include each file in `files=...`."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not files:
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["files"],
remediate=(
"i_documented requires files=['<path>', ...]"
" listing the doc files written."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
return None
async def i_documented( async def i_documented(
self, self,
doc_agent_id: UUID, doc_agent_id: UUID,
@@ -104,61 +167,27 @@ class DocMixin:
Transitions awaiting_documentation → awaiting_pm_review. Transitions awaiting_documentation → awaiting_pm_review.
""" """
t = await self.task.get(task_id) # type: ignore[attr-defined] rejection, _ = await self._verify_doc_owner(doc_agent_id, task_id)
if t is None: if rejection is not None:
return await self._emit_rejection( # type: ignore[attr-defined] return rejection
Envelope.not_found(message=f"task {task_id} not found"), input_rejection = await self._check_i_documented_inputs(
agent_id=doc_agent_id, doc_agent_id, task_id, notes, files
task_id=task_id,
verb="i_documented",
) )
if t.assigned_to != doc_agent_id: if input_rejection is not None:
return await self._emit_rejection( # type: ignore[attr-defined] return input_rejection
Envelope.not_authorized(
message="not assigned to you", # TaskService.docs_complete signature is (task_id, doc_notes); it
remediate="claim it via claim_doc_task(task_id) first", # reads task.documents for indexing. Stamp the file list onto the
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined] # task before the transition so the indexer sees it.
), existing = await self.task.get(task_id)
agent_id=doc_agent_id, if existing is not None:
task_id=task_id, existing.documents = files
verb="i_documented", await self.task.session.flush()
) t = await self.task.docs_complete(task_id, doc_notes=notes)
if not notes or len(notes) < settings.docs_notes_min_chars: pm_agent = await self.task.cell_pm_for_team(t.team)
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.tracing_gap(
missing=["docs_notes>=20"],
remediate=(
"i_documented requires notes>=20 chars summarizing what you "
"documented and where (file paths)."
" Include each file in `files=...`."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not files:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.tracing_gap(
missing=["files"],
remediate=(
"i_documented requires files=['<path>', ...]"
" listing the doc files written."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
t = await self.task.docs_complete( # type: ignore[attr-defined]
doc_agent_id, task_id, notes=notes, files=files
)
pm_agent = await self.task.cell_pm_for_team(t.team) # type: ignore[attr-defined]
if pm_agent is not None: if pm_agent is not None:
await self.task.reassign(task_id, pm_agent.id) # type: ignore[attr-defined] await self.task.reassign(task_id, pm_agent.id)
await self.a2a.send( # type: ignore[attr-defined] await self.a2a.send(
from_agent=doc_agent_id, from_agent=doc_agent_id,
to_agent=pm_agent.id, to_agent=pm_agent.id,
skill="task_management", skill="task_management",
@@ -169,5 +198,5 @@ class DocMixin:
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
next="idle until PM completes", next="idle until PM completes",
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(doc_agent_id, task_id),
) )
+98 -101
View File
@@ -4,6 +4,10 @@ Mixin for ``claim_review``, ``pass_review``, ``fail_review`` and the
two QA-specific helpers ``_check_qa_pass_gates`` / ``_qa_tracing_gap``. two QA-specific helpers ``_check_qa_pass_gates`` / ``_qa_tracing_gap``.
Helpers stay together with the verbs that use them — they're not used Helpers stay together with the verbs that use them — they're not used
by any other role. by any other role.
Inherits from ``ChoreographerHelpers`` under ``TYPE_CHECKING`` only so
mypy resolves ``self.task`` etc. as typed; at runtime the composed
``Choreographer`` supplies the real attributes via MRO.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,8 +21,14 @@ from roboco.services.gateway.evidence_builder import build_evidence_for_task
if TYPE_CHECKING: if TYPE_CHECKING:
from uuid import UUID from uuid import UUID
from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers
class QAMixin: _Base = ChoreographerHelpers
else:
_Base = object
class QAMixin(_Base):
"""QA-role verbs.""" """QA-role verbs."""
async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope: async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope:
@@ -28,30 +38,30 @@ class QAMixin:
journal_highlights, acceptance_criteria_status) INLINE so the QA agent journal_highlights, acceptance_criteria_status) INLINE so the QA agent
cannot miss the PR data. Marks `qa_evidence_inspected=true` automatically. cannot miss the PR data. Marks `qa_evidence_inspected=true` automatically.
""" """
t = await self.task.get(task_id) # type: ignore[attr-defined] t = await self.task.get(task_id)
if t is None: if t is None:
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"), Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id, agent_id=qa_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_review", verb="claim_review",
) )
if str(t.status) != "awaiting_qa": if str(t.status) != "awaiting_qa":
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
Envelope.invalid_state( Envelope.invalid_state(
message=( message=(
f"task {task_id} is in {t.status}, " f"task {task_id} is in {t.status}, "
"expected awaiting_qa for review" "expected awaiting_qa for review"
), ),
remediate="call give_me_work() to find an actionable QA task", remediate="call give_me_work() to find an actionable QA task",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(qa_agent_id, task_id),
), ),
agent_id=qa_agent_id, agent_id=qa_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_review", verb="claim_review",
) )
guard = await self._run_claim_guards( # type: ignore[attr-defined] guard = await self._run_claim_guards(
agent_id=qa_agent_id, agent_id=qa_agent_id,
task=t, task=t,
skip_role_typed=True, skip_role_typed=True,
@@ -59,27 +69,27 @@ class QAMixin:
skip_sequence=True, skip_sequence=True,
) )
if guard: if guard:
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
self._with_briefing( # type: ignore[attr-defined] self._with_briefing(
guard, guard,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] await self._briefing_for(qa_agent_id, task_id),
), ),
agent_id=qa_agent_id, agent_id=qa_agent_id,
task_id=task_id, task_id=task_id,
verb="claim_review", verb="claim_review",
) )
t = await self.task.qa_claim(qa_agent_id, task_id) # type: ignore[attr-defined] t = await self.task.qa_claim(qa_agent_id, task_id)
await self.task.mark_evidence_inspected(task_id) # type: ignore[attr-defined] await self.task.mark_evidence_inspected(task_id)
files_changed: list[str] = [] files_changed: list[str] = []
if t.work_session_id: if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id) # type: ignore[attr-defined] files_changed = await self.work_session.files_changed(t.work_session_id)
diff_summary = "" diff_summary = ""
if t.branch_name: if t.branch_name:
diff_summary = await self.git.diff(branch_name=t.branch_name) # type: ignore[attr-defined] diff_summary = await self.git.diff(branch_name=t.branch_name)
journal_highlights = ( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
await self.evidence_repo.journal_highlights_for_task(task_id) # type: ignore[attr-defined] task_id
) )
ev = build_evidence_for_task( ev = build_evidence_for_task(
t, t,
@@ -95,59 +105,76 @@ class QAMixin:
"fail(issues) to request changes." "fail(issues) to request changes."
), ),
evidence=ev.as_dict(), evidence=ev.as_dict(),
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(qa_agent_id, task_id),
)
async def _verify_qa_owner(
self, qa_agent_id: UUID, task_id: UUID, verb: str
) -> tuple[Envelope | None, Any]:
"""Lookup task + verify QA is the assignee. Returns (rejection, task)."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb=verb,
), None
if t.assigned_to != qa_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb=verb,
), None
return None, t
async def _qa_pass_gate_check(
self, qa_agent_id: UUID, task_id: UUID, notes: str, t: Any, verb: str
) -> Envelope | None:
"""QA pass-gate evaluation. Returns rejection envelope or None on pass."""
has_learning = await self.journal.has_learning_for_task(qa_agent_id, task_id)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if not missing:
return None
return await self._emit_rejection(
self._qa_tracing_gap(
missing,
task_id,
await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb=verb,
) )
async def pass_review( async def pass_review(
self, qa_agent_id: UUID, task_id: UUID, notes: str self, qa_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope: ) -> Envelope:
"""QA passes the task; transitions awaiting_qa → awaiting_documentation.""" """QA passes the task; transitions awaiting_qa → awaiting_documentation."""
t = await self.task.get(task_id) # type: ignore[attr-defined] rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "pass_review")
if t is None: if rejection is not None:
return await self._emit_rejection( # type: ignore[attr-defined] return rejection
Envelope.not_found(message=f"task {task_id} not found"), gate_rejection = await self._qa_pass_gate_check(
agent_id=qa_agent_id, qa_agent_id, task_id, notes, t, "pass_review"
task_id=task_id,
verb="pass_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
) )
if gate_rejection is not None:
return gate_rejection
has_learning = await self.journal.has_learning_for_task( # type: ignore[attr-defined] t = await self.task.qa_pass(qa_agent_id, task_id, notes)
qa_agent_id, task_id
)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection( # type: ignore[attr-defined]
self._qa_tracing_gap(
missing,
task_id,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
t = await self.task.qa_pass(qa_agent_id, task_id, notes) # type: ignore[attr-defined] doc_agent = await self.task.documenter_for_team(t.team)
doc_agent = await self.task.documenter_for_team(t.team) # type: ignore[attr-defined]
if doc_agent is not None: if doc_agent is not None:
await self.task.reassign(task_id, doc_agent.id) # type: ignore[attr-defined] await self.task.reassign(task_id, doc_agent.id)
await self.a2a.send( # type: ignore[attr-defined] await self.a2a.send(
from_agent=qa_agent_id, from_agent=qa_agent_id,
to_agent=doc_agent.id, to_agent=doc_agent.id,
skill="documentation", skill="documentation",
@@ -158,7 +185,7 @@ class QAMixin:
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
next="idle until next QA work arrives", next="idle until next QA work arrives",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(qa_agent_id, task_id),
) )
@staticmethod @staticmethod
@@ -204,61 +231,31 @@ class QAMixin:
self, qa_agent_id: UUID, task_id: UUID, issues: list[str] self, qa_agent_id: UUID, task_id: UUID, issues: list[str]
) -> Envelope: ) -> Envelope:
"""QA fails the task with concrete issues; transitions to needs_revision.""" """QA fails the task with concrete issues; transitions to needs_revision."""
t = await self.task.get(task_id) # type: ignore[attr-defined] rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "fail_review")
if t is None: if rejection is not None:
return await self._emit_rejection( # type: ignore[attr-defined] return rejection
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if not issues: if not issues:
return await self._emit_rejection( # type: ignore[attr-defined] return await self._emit_rejection(
Envelope.invalid_state( Envelope.invalid_state(
message="fail_review requires at least one issue", message="fail_review requires at least one issue",
remediate="pass issues=['<concrete actionable issue>', ...]", remediate="pass issues=['<concrete actionable issue>', ...]",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(qa_agent_id, task_id),
), ),
agent_id=qa_agent_id, agent_id=qa_agent_id,
task_id=task_id, task_id=task_id,
verb="fail_review", verb="fail_review",
) )
has_learning = await self.journal.has_learning_for_task( # type: ignore[attr-defined]
qa_agent_id, task_id
)
notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues) notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues)
missing = self._check_qa_pass_gates( gate_rejection = await self._qa_pass_gate_check(
notes=notes, qa_agent_id, task_id, notes, t, "fail_review"
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection( # type: ignore[attr-defined]
self._qa_tracing_gap(
missing,
task_id,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
) )
if gate_rejection is not None:
return gate_rejection
t = await self.task.qa_fail(qa_agent_id, task_id, notes, issues) # type: ignore[attr-defined] t = await self.task.qa_fail(qa_agent_id, task_id, notes, issues)
if t.assigned_to is not None: if t.assigned_to is not None:
await self.a2a.send( # type: ignore[attr-defined] await self.a2a.send(
from_agent=qa_agent_id, from_agent=qa_agent_id,
to_agent=t.assigned_to, to_agent=t.assigned_to,
skill="code_review", skill="code_review",
@@ -269,5 +266,5 @@ class QAMixin:
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
next="idle — dev will revise and re-submit", next="idle — dev will revise and re-submit",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined] context_briefing=await self._briefing_for(qa_agent_id, task_id),
) )
+140 -47
View File
@@ -11,6 +11,7 @@ import asyncio
import base64 import base64
import re import re
import subprocess import subprocess
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID from uuid import UUID
@@ -1619,11 +1620,37 @@ class GitService(BaseService):
project = await project_service.get(UUID(str(task.project_id))) project = await project_service.get(UUID(str(task.project_id)))
return project.slug if project else None return project.slug if project else None
async def _workspace_for_branch(self, branch_name: str) -> Path: @staticmethod
def _resolve_workspace_agent_id(
task: Any, actor_agent_id: UUID | None
) -> UUID | None:
"""Workspace-agent resolution priority (audit D-40).
actor_agent_id task.assigned_to task.created_by None.
Centralised so push_branch/create_pr/commit/diff/pr_target/pr_merge
share one chain and individual methods stay below the
cyclomatic-complexity gate (xenon B).
"""
candidate = actor_agent_id or (
UUID(str(task.assigned_to)) if task.assigned_to is not None else None
)
if candidate is None and task.created_by:
candidate = UUID(str(task.created_by))
return candidate
async def _workspace_for_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> Path:
"""Get a workspace where this branch can be operated on. """Get a workspace where this branch can be operated on.
Uses the assignee's workspace when one is recorded; otherwise Resolves the workspace via ``_resolve_workspace_agent_id`` (the
falls back to the project's static workspace_path. actor assignee creator fallback chain). Without it, post-
handoff calls (e.g. pr_target on a task whose assigned_to was
cleared by submit_qa) raise ValidationError when
project.workspace_path is unset.
""" """
task = await self._task_for_branch(branch_name) task = await self._task_for_branch(branch_name)
if task is None: if task is None:
@@ -1632,18 +1659,26 @@ class GitService(BaseService):
project = await project_service.get(UUID(str(task.project_id))) project = await project_service.get(UUID(str(task.project_id)))
if project is None: if project is None:
raise NotFoundError("Project", str(task.project_id)) raise NotFoundError("Project", str(task.project_id))
agent_id = UUID(str(task.assigned_to)) if task.assigned_to is not None else None workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
return await self.get_workspace(project.slug, agent_id=agent_id) return await self.get_workspace(project.slug, agent_id=workspace_agent_id)
async def push_branch(self, branch_name: str) -> tuple[str, int]: async def push_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> tuple[str, int]:
"""Push `branch_name` to origin from the assignee's workspace. """Push `branch_name` to origin from the assignee's workspace.
Gateway-only entry point the legacy `push(workspace, force)` Gateway-only entry point the legacy `push(workspace, force)`
signature stays intact for non-gateway callers. Resolves the signature stays intact for non-gateway callers. Resolves the
workspace from the task that owns the branch, then delegates. workspace from the task that owns the branch (with caller-actor
Returns (branch, commits_pushed). fallback per audit D-40), then delegates. Returns
(branch, commits_pushed).
""" """
workspace = await self._workspace_for_branch(branch_name) workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
return await self.push(workspace) return await self.push(workspace)
async def create_pr( async def create_pr(
@@ -1652,6 +1687,7 @@ class GitService(BaseService):
*, *,
parent: str, parent: str,
is_root_pr: bool, is_root_pr: bool,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Open a PR for `branch_name` targeting `parent`. """Open a PR for `branch_name` targeting `parent`.
@@ -1659,6 +1695,10 @@ class GitService(BaseService):
underlying project + task from the branch name; the gateway never underlying project + task from the branch name; the gateway never
passes a project_slug. The `parent` arg supersedes the task's passes a project_slug. The `parent` arg supersedes the task's
natural parent so root PRs can target master. natural parent so root PRs can target master.
``actor_agent_id`` lets PMs opening the master PR (where
``task.assigned_to`` may be None at completion time) resolve a
workspace via the actor's clone (audit D-40).
""" """
task = await self._task_for_branch(branch_name) task = await self._task_for_branch(branch_name)
if task is None: if task is None:
@@ -1668,7 +1708,9 @@ class GitService(BaseService):
if project is None: if project is None:
raise NotFoundError("Project", str(task.project_id)) raise NotFoundError("Project", str(task.project_id))
workspace = await self._workspace_for_branch(branch_name) workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
@@ -1740,6 +1782,52 @@ class GitService(BaseService):
.with_for_update(of=_TaskTable) .with_for_update(of=_TaskTable)
) )
@staticmethod
def _resolve_merger_id(task: Any, actor_agent_id: UUID | None) -> UUID:
"""merged_by attribution priority for pr_merge (audit D-43).
actor assigned_to created_by UUID(int=0) sentinel.
``UUID(0)`` is the explicit "nothing was recoverable" marker
instead of the silent NULL we used to write.
"""
merger = (
actor_agent_id
or (UUID(str(task.assigned_to)) if task.assigned_to else None)
or (UUID(str(task.created_by)) if task.created_by else None)
)
return merger or UUID(int=0)
@dataclass(frozen=True)
class _MergeContext:
"""Bundle of params for `_merge_with_retry` (keeps arg count under 5)."""
owner: str
repo: str
pr_number: int
git_token: str
workspace: Path
target: str
async def _merge_with_retry(self, ctx: GitService._MergeContext) -> Any:
"""Single-retry merge: on 409 (race), sync target then retry once."""
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if resp.status_code == _HTTP_CONFLICT:
# Another PM merged a sibling subtask first and our local target
# ref is stale. Refresh and retry once; a second 409 is a real
# conflict the PM resolves manually.
await self._sync_target_branch(ctx.workspace, ctx.target, ctx.git_token)
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number},
)
return resp
async def pr_merge( async def pr_merge(
self, self,
pr_number: int, pr_number: int,
@@ -1755,8 +1843,7 @@ class GitService(BaseService):
Concurrency: takes a row-level lock on the parent task before Concurrency: takes a row-level lock on the parent task before
invoking the GitHub merge API so that two PMs completing invoking the GitHub merge API so that two PMs completing
sibling subtasks of the same parent are serialized. On a 409 sibling subtasks of the same parent are serialized. On a 409
merge conflict (typical race symptom GitHub serializes via merge conflict the local target branch is re-pulled and the
PR-state churn) the local target branch is re-pulled and the
merge is retried exactly once before giving up with `GitError`. merge is retried exactly once before giving up with `GitError`.
""" """
from sqlalchemy import select from sqlalchemy import select
@@ -1774,53 +1861,47 @@ class GitService(BaseService):
if project is None: if project is None:
raise NotFoundError("Project", str(task.project_id)) raise NotFoundError("Project", str(task.project_id))
# Workspace resolution priority: caller-provided actor (the PM workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
# doing the merge) > task.assigned_to > created_by. assigned_to
# is often None at merge time because submit_qa / pass_qa cleared
# it during prior transitions; without a fallback the resolver
# raises ValidationError when project.workspace_path is unset.
workspace_agent_id = actor_agent_id or (
UUID(str(task.assigned_to)) if task.assigned_to else None
)
if workspace_agent_id is None and task.created_by:
workspace_agent_id = UUID(str(task.created_by))
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id) workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
# Serialize merges into the same parent branch — see helper docstring.
parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None
await self._lock_parent_task_for_merge(parent_id) await self._lock_parent_task_for_merge(parent_id)
resp = await self._call_merge_api(owner, repo, pr_number, git_token, "squash") await self._merge_with_retry(
if resp.status_code == _HTTP_CONFLICT: self._MergeContext(
# Race symptom — another PM merged a sibling subtask first owner=owner,
# and our local target ref is stale. Refresh and retry once; repo=repo,
# if the second attempt also conflicts, it's a real conflict pr_number=pr_number,
# (not just a race) and the choreographer surfaces it as git_token=git_token,
# `invalid_state` so the PM can resolve it manually. workspace=workspace,
await self._sync_target_branch(workspace, target, git_token) target=target,
resp = await self._call_merge_api(
owner, repo, pr_number, git_token, "squash"
) )
if not resp.is_success:
raise GitError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
) )
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token) await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
merge_commit = await self._sync_target_branch(workspace, target, git_token) merge_commit = await self._sync_target_branch(workspace, target, git_token)
if task.work_session_id: if task.work_session_id:
ws_service = get_work_session_service(self.session) ws_service = get_work_session_service(self.session)
await ws_service.merge_pr( await ws_service.merge_pr(
require_uuid(task.work_session_id), require_uuid(task.work_session_id),
UUID(str(task.assigned_to)) if task.assigned_to else UUID(int=0), self._resolve_merger_id(task, actor_agent_id),
) )
return {"merge_commit_sha": merge_commit or None} return {"merge_commit_sha": merge_commit or None}
async def pr_target(self, pr_number: int) -> str: async def pr_target(
"""Return the current target (base) branch of an open PR.""" self,
pr_number: int,
*,
actor_agent_id: UUID | None = None,
) -> str:
"""Return the current target (base) branch of an open PR.
Workspace resolution mirrors pr_merge: actor assigned_to
created_by (audit D-40). Lets the Main PM call pr_target after
``submit_qa`` has cleared ``assigned_to`` without ValidationError.
"""
from sqlalchemy import select from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable from roboco.db.tables import TaskTable as _TaskTable
@@ -1836,10 +1917,8 @@ class GitService(BaseService):
if project is None: if project is None:
raise NotFoundError("Project", str(task.project_id)) raise NotFoundError("Project", str(task.project_id))
workspace = await self.get_workspace( workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
project.slug, workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
agent_id=UUID(str(task.assigned_to)) if task.assigned_to else None,
)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
@@ -1875,6 +1954,7 @@ class GitService(BaseService):
*, *,
branch_name: str, branch_name: str,
base: str | None = None, base: str | None = None,
actor_agent_id: UUID | None = None,
) -> str: ) -> str:
"""Return the git diff for `branch_name` against `base`. """Return the git diff for `branch_name` against `base`.
@@ -1882,10 +1962,16 @@ class GitService(BaseService):
`parent_branch_for`) which is what the choreographer/PR-review `parent_branch_for`) which is what the choreographer/PR-review
path wants. Content_actions evidence path can pass `HEAD~1` to path wants. Content_actions evidence path can pass `HEAD~1` to
get just the latest change diff for incremental review. get just the latest change diff for incremental review.
``actor_agent_id`` resolves the workspace via the caller's clone
when ``task.assigned_to`` is None (audit D-40) important for
QA reviewing post-submit_qa.
""" """
from roboco.services.gateway.merge_chain import parent_branch_for from roboco.services.gateway.merge_chain import parent_branch_for
workspace = await self._workspace_for_branch(branch_name) workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
if base is None: if base is None:
parent = parent_branch_for(branch_name) parent = parent_branch_for(branch_name)
# Make sure the parent ref exists locally before diffing. # Make sure the parent ref exists locally before diffing.
@@ -1903,6 +1989,7 @@ class GitService(BaseService):
message: str, message: str,
task_id: UUID, task_id: UUID,
files: list[str] | None = None, files: list[str] | None = None,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Gateway adapter — commit on `branch_name` with a free-form message. """Gateway adapter — commit on `branch_name` with a free-form message.
@@ -1913,12 +2000,18 @@ class GitService(BaseService):
message is descriptive, and the orchestrator-side git template only message is descriptive, and the orchestrator-side git template only
applies to the structured `commit_for_task` API path. applies to the structured `commit_for_task` API path.
``actor_agent_id`` falls back through the same chain as pr_merge
when ``task.assigned_to`` was cleared by an earlier transition
(audit D-40).
Returns a dict shaped for the gateway: ``{"sha": str, "message": str, Returns a dict shaped for the gateway: ``{"sha": str, "message": str,
"files_changed": int, "insertions": int, "deletions": int}``. Tests "files_changed": int, "insertions": int, "deletions": int}``. Tests
and downstream gateway code only consume `sha`; the rest is included and downstream gateway code only consume `sha`; the rest is included
so we don't have to invent a new shape later. so we don't have to invent a new shape later.
""" """
workspace = await self._workspace_for_branch(branch_name) workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
await self._assert_on_task_branch(workspace, branch_name) await self._assert_on_task_branch(workspace, branch_name)
# Stage files explicitly when provided; otherwise stage everything # Stage files explicitly when provided; otherwise stage everything
+37 -30
View File
@@ -287,30 +287,22 @@ class NotificationService:
) )
) )
async def _create_notification(self, params: CreateNotificationParams) -> None: @staticmethod
"""Create a notification via the database and deliver it.""" def _notification_type_label(params: CreateNotificationParams) -> str:
async with get_db_context() as db: """Render the notification_type for a log line."""
from_agent_uuid = await _resolve_agent_uuid(db, params.from_agent) nt = params.notification_type
if from_agent_uuid is None: return nt.value if hasattr(nt, "value") else str(nt)
# Caller passed "system" / "unknown" / an unknown slug.
# notifications.from_agent is NOT NULL + FK to agents.id, async def _resolve_recipients(
# so we cannot insert. Skip-with-warn rather than crash self, db: Any, params: CreateNotificationParams
# the upstream request — the notification would be ) -> list[UUID]:
# orphaned anyway (no sender the recipient could reply to). """Resolve to_agents (slugs/UUIDs) to UUID list. Drops unresolvable.
logger.warning(
"Skipping notification: from_agent could not be resolved to an agent UUID", # noqa: E501 notifications.to_agents is UUID[] callers across the codebase
from_agent_input=str(params.from_agent), pass slugs ("be-dev-1", "be-qa"). Resolve every recipient before
type=params.notification_type.value insert; drop (with warn) any that don't resolve instead of
if hasattr(params.notification_type, "value") letting asyncpg crash with "invalid UUID 'be-dev-1'".
else str(params.notification_type), """
subject=params.subject[:80],
to_agents=[str(a) for a in params.to_agents],
)
return
# notifications.to_agents is UUID[] — callers across the codebase
# pass slugs ("be-dev-1", "be-qa"). Resolve every recipient before
# insert; drop (with warn) any that don't resolve instead of
# letting asyncpg crash with "invalid UUID 'be-dev-1'".
to_agents_uuids: list[UUID] = [] to_agents_uuids: list[UUID] = []
unresolved: list[str] = [] unresolved: list[str] = []
for recipient in params.to_agents: for recipient in params.to_agents:
@@ -323,18 +315,33 @@ class NotificationService:
logger.warning( logger.warning(
"Dropping unresolved notification recipients", "Dropping unresolved notification recipients",
unresolved=unresolved, unresolved=unresolved,
type=params.notification_type.value type=self._notification_type_label(params),
if hasattr(params.notification_type, "value")
else str(params.notification_type),
subject=params.subject[:80], subject=params.subject[:80],
) )
return to_agents_uuids
async def _create_notification(self, params: CreateNotificationParams) -> None:
"""Create a notification via the database and deliver it."""
async with get_db_context() as db:
from_agent_uuid = await _resolve_agent_uuid(db, params.from_agent)
if from_agent_uuid is None:
# notifications.from_agent is NOT NULL + FK to agents.id, so
# we cannot insert. Skip-with-warn rather than crash the
# upstream request.
logger.warning(
"Skipping notification: from_agent unresolvable",
from_agent_input=str(params.from_agent),
type=self._notification_type_label(params),
subject=params.subject[:80],
to_agents=[str(a) for a in params.to_agents],
)
return
to_agents_uuids = await self._resolve_recipients(db, params)
if not to_agents_uuids: if not to_agents_uuids:
logger.warning( logger.warning(
"Skipping notification: no resolvable recipients", "Skipping notification: no resolvable recipients",
to_agents_input=[str(a) for a in params.to_agents], to_agents_input=[str(a) for a in params.to_agents],
type=params.notification_type.value type=self._notification_type_label(params),
if hasattr(params.notification_type, "value")
else str(params.notification_type),
subject=params.subject[:80], subject=params.subject[:80],
) )
return return
+31 -19
View File
@@ -135,6 +135,35 @@ class ProviderService(BaseService):
) )
return row return row
async def _apply_name_change(self, row: ProviderConfigTable, new_name: str) -> None:
"""Set row.name with duplicate-name guard."""
if new_name == row.name:
return
dup = await self.get_by_name(new_name)
if dup and dup.id != row.id:
raise ConflictError(
f"Provider with name '{new_name}' already exists",
resource_type="provider",
)
row.name = new_name
def _apply_auth_token_change(
self, row: ProviderConfigTable, data: ProviderUpdate
) -> None:
"""Tri-state token update: clear, set, or leave unchanged."""
if data.clear_auth_token:
row.auth_token_encrypted = None
self.log.info("Provider auth token cleared", provider_id=str(row.id))
return
if not data.auth_token:
return
try:
row.auth_token_encrypted = encrypt_token(data.auth_token)
except EncryptionError as e:
self.log.error("Failed to encrypt auth token", error=str(e))
raise
self.log.info("Provider auth token updated", provider_id=str(row.id))
async def update_provider( async def update_provider(
self, provider_id: UUID, data: ProviderUpdate self, provider_id: UUID, data: ProviderUpdate
) -> ProviderConfigTable | None: ) -> ProviderConfigTable | None:
@@ -144,31 +173,14 @@ class ProviderService(BaseService):
return None return None
if data.name is not None: if data.name is not None:
# Duplicate-name check only when actually changing the name. await self._apply_name_change(row, data.name)
if data.name != row.name:
dup = await self.get_by_name(data.name)
if dup and dup.id != row.id:
raise ConflictError(
f"Provider with name '{data.name}' already exists",
resource_type="provider",
)
row.name = data.name
if data.base_url is not None: if data.base_url is not None:
# Empty string → clear to NULL (matches git-token convention). # Empty string → clear to NULL (matches git-token convention).
row.base_url = data.base_url or None row.base_url = data.base_url or None
if data.enabled is not None: if data.enabled is not None:
row.enabled = data.enabled row.enabled = data.enabled
if data.clear_auth_token: self._apply_auth_token_change(row, data)
row.auth_token_encrypted = None
self.log.info("Provider auth token cleared", provider_id=str(row.id))
elif data.auth_token:
try:
row.auth_token_encrypted = encrypt_token(data.auth_token)
except EncryptionError as e:
self.log.error("Failed to encrypt auth token", error=str(e))
raise
self.log.info("Provider auth token updated", provider_id=str(row.id))
await self.session.flush() await self.session.flush()
return row return row
+88 -11
View File
@@ -1048,7 +1048,18 @@ class TaskService(BaseService):
async def _inject_proactive_context(self, task: TaskTable, agent_id: UUID) -> None: async def _inject_proactive_context(self, task: TaskTable, agent_id: UUID) -> None:
"""Inject proactive knowledge context when task is claimed. """Inject proactive knowledge context when task is claimed.
Runs as a background task, so uses its own database session. Runs as a background task with its own DB session. Pre-fix the
outer claim() transaction could roll back (branch-creation
failure, FOR UPDATE conflict, etc.) but this fire-and-forget
survived and wrote stale context onto a task whose claim was
reverted (audit D-44).
Now performs a confirm-after-commit check at the top: re-reads
the task in a fresh session and skips if (a) task is gone, or
(b) the claim is no longer held by ``agent_id``. Outer rollback
clears ``assigned_to``, so this guard is enough to avoid stale
writes; it also fires correctly under successful commits because
a fresh read sees the post-commit state.
""" """
from uuid import UUID as PyUUID from uuid import UUID as PyUUID
@@ -1060,6 +1071,17 @@ class TaskService(BaseService):
task_description = task.description or "" task_description = task.description or ""
try: try:
session_factory = get_session_factory()
async with session_factory() as session:
fresh = await session.get(TaskTable, task_id)
if fresh is None or fresh.assigned_to != agent_id:
self.log.debug(
"skipping proactive context — claim was rolled back"
" or task is gone",
task_id=str(task_id),
)
return
proactive = await get_proactive_service() proactive = await get_proactive_service()
agent_uuid = PyUUID(str(agent_id)) agent_uuid = PyUUID(str(agent_id))
@@ -1072,8 +1094,6 @@ class TaskService(BaseService):
) )
if context and not context.is_empty(): if context and not context.is_empty():
# Store context in the task using a fresh session
session_factory = get_session_factory()
async with session_factory() as session: async with session_factory() as session:
from sqlalchemy import update from sqlalchemy import update
@@ -1857,6 +1877,12 @@ class TaskService(BaseService):
VALID_TRANSITIONS making the lifecycle module's invariants diverge VALID_TRANSITIONS making the lifecycle module's invariants diverge
from production reality. from production reality.
Also abandons the active WorkSession so a re-claim by the same
agent doesn't trip the uniqueness constraint at
``WorkSessionService.create`` (audit D-41). Best-effort: if the
WorkSession lookup fails for any reason, the task is still
rolled back to pending.
The operation is named with ``_for_reaper`` so callers cannot The operation is named with ``_for_reaper`` so callers cannot
accidentally use it as a regular unclaim path; uses ``agent_role=None`` accidentally use it as a regular unclaim path; uses ``agent_role=None``
because the system itself is performing the transition. Bypasses because the system itself is performing the transition. Bypasses
@@ -1872,11 +1898,40 @@ class TaskService(BaseService):
self._validate_and_set_status(task, TaskStatus.PENDING, None) self._validate_and_set_status(task, TaskStatus.PENDING, None)
except TaskLifecycleError: except TaskLifecycleError:
return return
if task.work_session_id:
await self._abandon_work_session_best_effort(
task.work_session_id, reason="reaper-unclaim"
)
task.work_session_id = cast("Any", None)
task.assigned_to = cast("Any", None) task.assigned_to = cast("Any", None)
task.last_heartbeat_at = None task.last_heartbeat_at = None
task.active_claimant_id = cast("Any", None) task.active_claimant_id = cast("Any", None)
await self.session.flush() await self.session.flush()
async def _abandon_work_session_best_effort(
self, session_id: Any, *, reason: str
) -> None:
"""Mark a WorkSession ABANDONED. Logs and continues on any failure.
Audit D-41 fix unclaim must not leave ACTIVE WorkSessions
behind, but a service-layer error here mustn't block the task-
side unclaim from completing.
"""
try:
from roboco.services.work_session import (
WorkSessionService,
)
ws_service = WorkSessionService(self.session)
await ws_service.abandon(UUID(str(session_id)), reason=reason)
except Exception as exc:
self.log.warning(
"abandon WorkSession failed; continuing",
session_id=str(session_id),
reason=reason,
error=str(exc),
)
async def unclaim_for_agent( async def unclaim_for_agent(
self, task_id: UUID, agent_id: UUID self, task_id: UUID, agent_id: UUID
) -> TaskTable | None: ) -> TaskTable | None:
@@ -1925,7 +1980,14 @@ class TaskService(BaseService):
return None return None
# _validate_and_set_status only updates `status`; clearing the # _validate_and_set_status only updates `status`; clearing the
# claim is the unclaim's specific side effect. # claim is the unclaim's specific side effect. Also abandon the
# active WorkSession so a re-claim doesn't trip the uniqueness
# constraint (audit D-41).
if task.work_session_id:
await self._abandon_work_session_best_effort(
task.work_session_id, reason="agent-unclaim"
)
task.work_session_id = cast("Any", None)
task.assigned_to = cast("Any", None) task.assigned_to = cast("Any", None)
task.active_claimant_id = cast("Any", None) task.active_claimant_id = cast("Any", None)
await self.session.flush() await self.session.flush()
@@ -4217,22 +4279,26 @@ class TaskService(BaseService):
await self.session.commit() await self.session.commit()
return completed return completed
async def escalate_to_ceo_for_agent( async def _validate_escalation_preconditions(
self, self,
task: TaskTable,
task_id: UUID, task_id: UUID,
agent: AgentContext, agent: AgentContext,
permissions: "PermissionService", permissions: "PermissionService",
notes: str | None, notes: str | None,
) -> TaskTable: ) -> None:
"""Escalate a task to CEO for final approval (PM-role, PR-gated).""" """Run all PR/permission/descendants/notes gates for escalate-to-CEO.
task = await self._load_task_or_raise(task_id)
Raises UnauthorizedError or ValidationError on failure; returns
cleanly when every gate passes. Extracted from
``escalate_to_ceo_for_agent`` to keep that orchestrating method
below B-rank cyclomatic complexity (audit P2-2 / xenon).
"""
if not permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team): if not permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team):
raise UnauthorizedError( raise UnauthorizedError(
action="escalate_to_ceo", action="escalate_to_ceo",
reason="Only PMs can escalate tasks to CEO", reason="Only PMs can escalate tasks to CEO",
) )
if task.pr_number is None: if task.pr_number is None:
raise ValidationError( raise ValidationError(
"NO_PR: Cannot escalate to CEO without an open PR. Ensure " "NO_PR: Cannot escalate to CEO without an open PR. Ensure "
@@ -4243,7 +4309,6 @@ class TaskService(BaseService):
"PR_NOT_CONFIRMED: pr_created flag is false. The PR handler " "PR_NOT_CONFIRMED: pr_created flag is false. The PR handler "
"must confirm the PR exists before escalation." "must confirm the PR exists before escalation."
) )
descendants = await self.get_all_descendants(task_id) descendants = await self.get_all_descendants(task_id)
active = [ active = [
d d
@@ -4256,7 +4321,6 @@ class TaskService(BaseService):
f"ACTIVE_SUBTASKS: Cannot escalate while {len(active)} " f"ACTIVE_SUBTASKS: Cannot escalate while {len(active)} "
f"subtask(s) remain active: {ids_shown}." f"subtask(s) remain active: {ids_shown}."
) )
if not notes or len(notes.strip()) < self.MIN_NOTES_CHARS: if not notes or len(notes.strip()) < self.MIN_NOTES_CHARS:
raise ValidationError( raise ValidationError(
f"ESCALATION_NOTES_REQUIRED: Escalation to CEO must " f"ESCALATION_NOTES_REQUIRED: Escalation to CEO must "
@@ -4265,6 +4329,19 @@ class TaskService(BaseService):
"etc)." "etc)."
) )
async def escalate_to_ceo_for_agent(
self,
task_id: UUID,
agent: AgentContext,
permissions: "PermissionService",
notes: str | None,
) -> TaskTable:
"""Escalate a task to CEO for final approval (PM-role, PR-gated)."""
task = await self._load_task_or_raise(task_id)
await self._validate_escalation_preconditions(
task, task_id, agent, permissions, notes
)
escalated = await self.escalate_to_ceo(task_id, agent.role.value, notes) escalated = await self.escalate_to_ceo(task_id, agent.role.value, notes)
if not escalated: if not escalated:
raise ValidationError( raise ValidationError(
+139 -18
View File
@@ -72,8 +72,9 @@ class _StubGit:
message: str, message: str,
task_id: UUID, task_id: UUID,
files: list[str] | None = None, files: list[str] | None = None,
actor_agent_id: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
del branch_name, files del branch_name, files, actor_agent_id
sha = uuid4().hex[:40] sha = uuid4().hex[:40]
commits = list(self._task.commits or []) commits = list(self._task.commits or [])
commits.append({"sha": sha, "message": message, "task_id": str(task_id)}) commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
@@ -87,23 +88,38 @@ class _StubGit:
"deletions": 0, "deletions": 0,
} }
async def push_branch(self, branch_name: str) -> tuple[str, int]: async def push_branch(
del branch_name self, branch_name: str, *, actor_agent_id: Any = None
) -> tuple[str, int]:
del branch_name, actor_agent_id
return ("ok", 0) return ("ok", 0)
async def create_pr( async def create_pr(
self, branch_name: str, *, parent: str, is_root_pr: bool self,
branch_name: str,
*,
parent: str,
is_root_pr: bool,
actor_agent_id: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
del branch_name, parent del branch_name, parent, actor_agent_id
self._task.pr_number = _PR_NUMBER self._task.pr_number = _PR_NUMBER
self._task.pr_url = _PR_URL self._task.pr_url = _PR_URL
# Mirrors git._record_pr_atomically — production sets pr_created
# via mark_pr_created which is what the parallel-completion gate
# in _maybe_advance_to_pm_review reads.
self._task.pr_created = True
await self._session.flush() await self._session.flush()
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr} return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
async def diff(self, *, branch_name: str) -> str: # noqa: ARG002 async def diff(
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
) -> str:
del branch_name, base, actor_agent_id
return "stub diff" return "stub diff"
async def pr_target(self, pr_number: int) -> str: # noqa: ARG002 async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
del pr_number, actor_agent_id
return "main" return "main"
async def pr_merge(self, **kwargs: Any) -> dict[str, Any]: async def pr_merge(self, **kwargs: Any) -> dict[str, Any]:
@@ -204,7 +220,33 @@ async def lifecycle_setup(
permissions={}, permissions={},
metrics={}, metrics={},
) )
db_session.add_all([dev_agent, qa_agent]) doc_agent = AgentTable(
id=uuid4(),
name="BE Doc",
slug=f"be-doc-{uuid4().hex[:8]}",
role=AgentRole.DOCUMENTER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="doc",
capabilities=["docs"],
permissions={},
metrics={},
)
cell_pm_agent = AgentTable(
id=uuid4(),
name="BE Cell PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="cell_pm",
capabilities=["coord"],
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
await db_session.flush() await db_session.flush()
task = TaskTable( task = TaskTable(
@@ -233,6 +275,8 @@ async def lifecycle_setup(
"project": project, "project": project,
"dev_agent": dev_agent, "dev_agent": dev_agent,
"qa_agent": qa_agent, "qa_agent": qa_agent,
"doc_agent": doc_agent,
"cell_pm_agent": cell_pm_agent,
"task": task, "task": task,
} }
@@ -337,13 +381,90 @@ async def test_dev_full_chain_through_awaiting_qa(
assert final.self_verified is True, "P1-3: self_verified set by auto-verify" assert final.self_verified is True, "P1-3: self_verified set by auto-verify"
# TODO P2-1 follow-up — extend the chain past awaiting_qa: @pytest.mark.asyncio
# - QA: claim_review → pass → awaiting_documentation async def test_full_chain_through_doc_handoff(
# - Documenter: claim_doc_task → i_documented → awaiting_pm_review db_session: AsyncSession, lifecycle_setup: dict[str, Any]
# - Cell PM: complete on the leaf → completed (or submit_up to a parent) ) -> None:
# - Main PM: complete on the root → awaiting_ceo_approval """Extend the dev chain: QA pass → documenter → awaiting_pm_review.
# Each stage needs the role's agent seeded (lifecycle_setup already has
# dev + qa; add doc + cell_pm + main_pm) plus journal entries with the Verifies QA pass clears active_claimant_id (P1-4 + P1-5),
# right scope (pass needs journal:learning; complete needs journal:decision). docs_complete transitions to awaiting_pm_review, and reassignment
# The _StubGit class above already covers commit/push/pr_create/pr_target to the cell PM happens on hand-off.
# /pr_merge for the merge stages. """
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
qa_agent = lifecycle_setup["qa_agent"]
doc_agent = lifecycle_setup["doc_agent"]
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
task_service = TaskService(db_session)
stub_git = _StubGit(db_session, task)
deps = ChoreographerDeps(
task=task_service,
work_session=_mock_work_session(),
git=stub_git,
a2a=AsyncMock(),
journal=_mock_journal_with_reflect(),
audit=AsyncMock(),
evidence_repo=_mock_evidence_repo(),
)
c = Choreographer(deps)
# Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa).
await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
await stub_git.commit(
branch_name=_BRANCH,
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
task_id=task.id,
)
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
await c.submit_for_qa(dev_agent.id, task.id)
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
assert env.error is None
assert env.status == "awaiting_qa"
# QA path: claim_review → pass.
env = await c.claim_review(qa_agent.id, task.id)
assert env.error is None, f"claim_review failed: {env.message}"
qa_notes = (
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
"both acceptance criteria. Approving."
)
env = await c.pass_review(qa_agent.id, task.id, notes=qa_notes)
assert env.error is None, f"pass_review failed: {env.message}"
assert env.status == "awaiting_documentation"
after_qa = await task_service.get(task.id)
assert after_qa is not None
assert after_qa.active_claimant_id is None, (
"P1-4 + P1-5: QA pass must clear active_claimant_id for next role"
)
# Documenter path: claim_doc_task → i_documented.
env = await c.claim_doc_task(doc_agent.id, task.id)
assert env.error is None, f"claim_doc_task failed: {env.message}"
env = await c.i_documented(
doc_agent.id,
task.id,
notes="Documented /healthz behaviour in docs/api/health.md",
files=["docs/api/health.md"],
)
assert env.error is None, f"i_documented failed: {env.message}"
assert env.status == "awaiting_pm_review", (
"P2-1: i_documented must transition awaiting_documentation → awaiting_pm_review"
)
after_docs = await task_service.get(task.id)
assert after_docs is not None
assert after_docs.assigned_to == cell_pm_agent.id, (
"P2-1: docs_complete must reassign to the cell PM for the team"
)
# TODO P2-1 follow-up — final stages (cell_pm complete + main_pm complete +
# CEO approval) require additional setup: a parent task hierarchy for
# the merge chain, plus a real `git.pr_merge` simulation that updates
# the underlying repo. The _StubGit class covers the API surface; what's
# missing is the seeded parent task + main_pm agent.
@@ -0,0 +1,148 @@
"""P0-7 / S-01: branch creation atomicity.
When ``_ensure_branch_for_task`` raises (git checkout fails, push fails,
no token, etc.), ``_finalize_claim`` must roll back the claim fields it
just flushed otherwise the task is left CLAIMED with branch_name=NULL
and the next claim attempt collides on a non-idempotent
``git checkout -b``.
This test exercises the rollback path against a real Postgres session
by patching ``_ensure_branch_for_task`` to raise.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
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 claim_setup(db_session: AsyncSession) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Atom Test",
slug=f"atom-{uuid4().hex[:8]}",
git_url="https://github.com/example/atom.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
task = TaskTable(
id=uuid4(),
title="Task that will fail at branch creation",
description="",
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
acceptance_criteria=["does the thing"],
# No branch_name — claim path will try to create one.
)
db_session.add(task)
await db_session.flush()
yield {"task": task, "dev": dev, "project": project}
@pytest.mark.asyncio
async def test_finalize_claim_rolls_back_on_branch_failure(
db_session: AsyncSession, claim_setup: dict[str, Any]
) -> None:
"""git failure during _ensure_branch_for_task must revert claim fields.
Without rollback the task is left CLAIMED with branch_name=NULL and
`git checkout -b` is non-idempotent on retry.
"""
task = claim_setup["task"]
dev = claim_setup["dev"]
svc = TaskService(db_session)
# Snapshot pre-claim state so we can assert exact rollback.
pre_status = task.status
pre_assigned = task.assigned_to
pre_claimed_by = task.claimed_by
pre_claimed_at = task.claimed_at
pre_heartbeat = task.last_heartbeat_at
pre_claimant = task.active_claimant_id
async def boom(_self: Any, _task: Any, _agent_id: Any) -> str:
raise RuntimeError("simulated: git checkout -b failed")
with (
patch.object(TaskService, "_ensure_branch_for_task", boom),
pytest.raises(RuntimeError, match="git checkout -b failed"),
):
await svc.claim(task.id, dev.id)
# Re-read the task from a clean state via a fresh fetch.
refreshed = await svc.get(task.id)
assert refreshed is not None
assert refreshed.status == pre_status, "P0-7: status must roll back"
assert refreshed.assigned_to == pre_assigned, "P0-7: assigned_to must roll back"
assert refreshed.claimed_by == pre_claimed_by, "P0-7: claimed_by must roll back"
assert refreshed.claimed_at == pre_claimed_at, "P0-7: claimed_at must roll back"
assert refreshed.last_heartbeat_at == pre_heartbeat, (
"P0-7: heartbeat must roll back"
)
assert refreshed.active_claimant_id == pre_claimant, (
"P1-4 + P0-7: active_claimant_id must roll back too"
)
@@ -0,0 +1,149 @@
"""P2-8: startup orphan-claim reconciler.
The orchestrator's `_reconcile_orphan_claims_on_startup` rolls back
tasks left in CLAIMED/IN_PROGRESS with `branch_name IS NULL` the
half-state from a pre-P0-7 crash where `_finalize_claim` flushed
status=CLAIMED before branch creation failed.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.runtime.orchestrator import AgentOrchestrator
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 orphan_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Reconciler Test",
slug=f"recon-{uuid4().hex[:8]}",
git_url="https://github.com/example/recon.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
# ORPHAN: status=CLAIMED, assigned_to=dev, branch_name=NULL.
orphan = TaskTable(
id=uuid4(),
title="Orphan from prior crash",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
acceptance_criteria=[""],
)
# HEALTHY: status=CLAIMED with branch — must NOT be rolled back.
healthy = TaskTable(
id=uuid4(),
title="Healthy claim",
description="",
status=TaskStatus.CLAIMED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev.id,
claimed_by=dev.id,
branch_name="feature/backend/healthy",
acceptance_criteria=[""],
)
db_session.add_all([orphan, healthy])
await db_session.flush()
yield {"orphan": orphan, "healthy": healthy, "dev": dev}
@pytest.mark.asyncio
async def test_reconciler_rolls_back_orphan_claims(
db_session: AsyncSession, orphan_setup: dict[str, Any]
) -> None:
"""CLAIMED task with branch_name=NULL → reconciled to PENDING."""
orphan = orphan_setup["orphan"]
healthy = orphan_setup["healthy"]
svc = TaskService(db_session)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
# Drive the logic directly via the test-injectable helper so we avoid
# the orchestrator's session-factory dance.
await orch._reconcile_with_service(svc)
refreshed_orphan = await svc.get(orphan.id)
refreshed_healthy = await svc.get(healthy.id)
assert refreshed_orphan is not None
assert str(refreshed_orphan.status) == "pending", (
"P2-8: orphan must be rolled back to pending"
)
assert refreshed_orphan.assigned_to is None
assert refreshed_orphan.active_claimant_id is None
# Healthy claim untouched.
assert refreshed_healthy is not None
assert str(refreshed_healthy.status) == "claimed"
assert refreshed_healthy.branch_name == "feature/backend/healthy"
+261
View File
@@ -0,0 +1,261 @@
"""ProjectService coverage — register/list/update/delete + token round-trip.
Driven by the real Postgres ``db_session`` fixture so the test exercises
the same SQLAlchemy paths the production code does.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.project import ProjectService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def project_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Seed a system agent so created_by FK is satisfied."""
system = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system)
await db_session.flush()
svc = ProjectService(db_session)
yield {"svc": svc, "creator_id": system.id}
def _project_payload(slug_suffix: str) -> ProjectCreate:
return ProjectCreate(
name=f"Project {slug_suffix}",
slug=f"proj-{slug_suffix}",
git_url=f"https://github.com/example/{slug_suffix}.git",
assigned_cell=Team.BACKEND,
)
@pytest.mark.asyncio
async def test_create_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert project.id is not None
@pytest.mark.asyncio
async def test_create_project_with_git_token_encrypts(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
payload_dict = payload.model_dump()
payload_dict["git_token"] = "ghp_test_token"
project = await svc.create(
ProjectCreate(**payload_dict), project_setup["creator_id"]
)
assert project.git_token_encrypted is not None
assert project.git_token_encrypted != "ghp_test_token"
@pytest.mark.asyncio
async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
await svc.create(payload, project_setup["creator_id"])
with pytest.raises(ConflictError):
await svc.create(payload, project_setup["creator_id"])
@pytest.mark.asyncio
async def test_get_returns_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
fetched = await svc.get(project.id)
assert fetched is not None
assert fetched.id == project.id
@pytest.mark.asyncio
async def test_get_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert await svc.get(uuid4()) is None
@pytest.mark.asyncio
async def test_get_by_slug(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
created = await svc.create(payload, project_setup["creator_id"])
fetched = await svc.get_by_slug(payload.slug)
assert fetched is not None
assert fetched.id == created.id
@pytest.mark.asyncio
async def test_get_or_raise_raises(project_setup: dict) -> None:
svc = project_setup["svc"]
with pytest.raises(NotFoundError):
await svc.get_or_raise(uuid4())
@pytest.mark.asyncio
async def test_update_changes_name(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_name = f"renamed-{uuid4().hex[:6]}"
updated = await svc.update(project.id, ProjectUpdate(name=new_name))
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_clear_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_initial"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
assert project.git_token_encrypted is not None
updated = await svc.update(project.id, ProjectUpdate(git_token=""))
assert updated is not None
assert updated.git_token_encrypted is None
@pytest.mark.asyncio
async def test_update_set_git_token(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.update(project.id, ProjectUpdate(git_token="ghp_new"))
assert updated is not None
assert updated.git_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_returns_none_for_missing(project_setup: dict) -> None:
svc = project_setup["svc"]
assert (await svc.update(uuid4(), ProjectUpdate(name="ghost"))) is None
@pytest.mark.asyncio
async def test_delete_project(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
await svc.delete(project.id)
assert await svc.get(project.id) is None
@pytest.mark.asyncio
async def test_list_all(project_setup: dict) -> None:
svc = project_setup["svc"]
a = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
b = await svc.create(_project_payload(uuid4().hex[:6]), project_setup["creator_id"])
rows = await svc.list_all()
ids = {p.id for p in rows}
assert a.id in ids
assert b.id in ids
@pytest.mark.asyncio
async def test_list_by_cell(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
rows = await svc.list_by_cell(Team.BACKEND)
assert project.id in {p.id for p in rows}
@pytest.mark.asyncio
async def test_set_workspace_path(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
updated = await svc.set_workspace_path(project.id, "/tmp/test-ws")
assert updated is not None
assert updated.workspace_path == "/tmp/test-ws"
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(project_setup: dict) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_secret"
project = await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token(project.id)
assert decrypted == "ghp_secret"
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
assert await svc.get_decrypted_token(project.id) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_by_slug_round_trip(
project_setup: dict,
) -> None:
svc = project_setup["svc"]
payload = _project_payload(uuid4().hex[:6])
pd = payload.model_dump()
pd["git_token"] = "ghp_slug_secret"
await svc.create(ProjectCreate(**pd), project_setup["creator_id"])
decrypted = await svc.get_decrypted_token_by_slug(payload.slug)
assert decrypted == "ghp_slug_secret"
@pytest.mark.asyncio
async def test_check_agent_access_returns_bool(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
has_access = await svc.check_agent_access(project.id, uuid4(), Team.BACKEND)
assert isinstance(has_access, bool)
@pytest.mark.asyncio
async def test_add_and_remove_allowed_agent(project_setup: dict) -> None:
svc = project_setup["svc"]
project = await svc.create(
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
)
new_agent_id = uuid4()
added = await svc.add_allowed_agent(project.id, new_agent_id)
assert added is not None
removed = await svc.remove_allowed_agent(project.id, new_agent_id)
assert removed is not None
+303
View File
@@ -0,0 +1,303 @@
"""ProviderService coverage — list/get/create/update/delete/decrypt.
Drives a real `db_session` via the project's Postgres-backed conftest.
Provider rows are encrypted at rest with Fernet; tests round-trip
plaintext ciphertext plaintext through `get_decrypted_token` and
exercise the tri-state semantics of ``ProviderUpdate.auth_token``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import ModelAssignmentTable
from roboco.models.base import ModelProvider
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.provider import (
ProviderCreate,
ProviderService,
ProviderUpdate,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def provider_svc(db_session: AsyncSession) -> AsyncIterator[ProviderService]:
yield ProviderService(db_session)
@pytest.mark.asyncio
async def test_create_provider_with_token_encrypts(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"anthropic-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="sk-test-secret",
)
)
assert row.auth_token_encrypted is not None
assert row.auth_token_encrypted != "sk-test-secret"
@pytest.mark.asyncio
async def test_create_provider_without_token_leaves_null(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"p-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert row.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_create_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
name = f"dup-{uuid4().hex[:6]}"
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
with pytest.raises(ConflictError):
await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.OPENAI)
)
@pytest.mark.asyncio
async def test_get_provider_returns_row(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"g-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
fetched = await provider_svc.get_provider(row.id)
assert fetched is not None
assert fetched.id == row.id
@pytest.mark.asyncio
async def test_get_provider_returns_none_when_missing(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_provider(uuid4()) is None
@pytest.mark.asyncio
async def test_get_provider_or_raise_raises(provider_svc: ProviderService) -> None:
with pytest.raises(NotFoundError):
await provider_svc.get_provider_or_raise(uuid4())
@pytest.mark.asyncio
async def test_get_by_name(provider_svc: ProviderService) -> None:
name = f"by-name-{uuid4().hex[:6]}"
row = await provider_svc.create_provider(
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
)
found = await provider_svc.get_by_name(name)
assert found is not None
assert found.id == row.id
@pytest.mark.asyncio
async def test_list_providers_excludes_disabled_by_default(
provider_svc: ProviderService,
) -> None:
enabled = await provider_svc.create_provider(
ProviderCreate(
name=f"on-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC, enabled=True
)
)
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"off-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
visible = await provider_svc.list_providers()
visible_ids = {p.id for p in visible}
assert enabled.id in visible_ids
assert disabled.id not in visible_ids
@pytest.mark.asyncio
async def test_list_providers_include_disabled(provider_svc: ProviderService) -> None:
disabled = await provider_svc.create_provider(
ProviderCreate(
name=f"x-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
)
)
every = await provider_svc.list_providers(include_disabled=True)
assert disabled.id in {p.id for p in every}
@pytest.mark.asyncio
async def test_update_provider_changes_name(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"old-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
new_name = f"new-{uuid4().hex[:6]}"
updated = await provider_svc.update_provider(row.id, ProviderUpdate(name=new_name))
assert updated is not None
assert updated.name == new_name
@pytest.mark.asyncio
async def test_update_provider_duplicate_name_raises(
provider_svc: ProviderService,
) -> None:
a = await provider_svc.create_provider(
ProviderCreate(name=f"a-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
b = await provider_svc.create_provider(
ProviderCreate(name=f"b-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
with pytest.raises(ConflictError):
await provider_svc.update_provider(b.id, ProviderUpdate(name=a.name))
@pytest.mark.asyncio
async def test_update_provider_clears_base_url_with_empty_string(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"url-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
base_url="https://example.com",
)
)
updated = await provider_svc.update_provider(row.id, ProviderUpdate(base_url=""))
assert updated is not None
assert updated.base_url is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_clear(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"t-{uuid4().hex[:6]}",
type=ModelProvider.OLLAMA_CLOUD,
auth_token="initial",
)
)
updated = await provider_svc.update_provider(
row.id, ProviderUpdate(clear_auth_token=True)
)
assert updated is not None
assert updated.auth_token_encrypted is None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_set(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"s-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
)
updated = await provider_svc.update_provider(
row.id, ProviderUpdate(auth_token="new-secret")
)
assert updated is not None
assert updated.auth_token_encrypted is not None
@pytest.mark.asyncio
async def test_update_provider_token_tristate_unchanged(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(
name=f"u-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token="initial",
)
)
original_token = row.auth_token_encrypted
updated = await provider_svc.update_provider(
row.id,
ProviderUpdate(enabled=False), # no auth_token field
)
assert updated is not None
assert updated.auth_token_encrypted == original_token # unchanged
assert updated.enabled is False
@pytest.mark.asyncio
async def test_update_provider_returns_none_for_missing(
provider_svc: ProviderService,
) -> None:
assert (
await provider_svc.update_provider(uuid4(), ProviderUpdate(enabled=False))
is None
)
@pytest.mark.asyncio
async def test_delete_provider(provider_svc: ProviderService) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"d-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
await provider_svc.delete_provider(row.id)
assert await provider_svc.get_provider(row.id) is None
@pytest.mark.asyncio
async def test_delete_provider_raises_when_referenced(
db_session: AsyncSession, provider_svc: ProviderService
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"r-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
)
# Insert a model assignment that references this provider.
assignment = ModelAssignmentTable(
id=uuid4(),
scope="role",
scope_value="developer",
provider_config_id=row.id,
model_name="claude-haiku-4-5",
)
db_session.add(assignment)
await db_session.flush()
with pytest.raises(ConflictError):
await provider_svc.delete_provider(row.id)
@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(provider_svc: ProviderService) -> None:
plaintext = "sk-roundtrip-secret"
row = await provider_svc.create_provider(
ProviderCreate(
name=f"rt-{uuid4().hex[:6]}",
type=ModelProvider.ANTHROPIC,
auth_token=plaintext,
)
)
decrypted = await provider_svc.get_decrypted_token(row.id)
assert decrypted == plaintext
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(
provider_svc: ProviderService,
) -> None:
row = await provider_svc.create_provider(
ProviderCreate(name=f"nt-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
)
assert await provider_svc.get_decrypted_token(row.id) is None
@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_for_missing_provider(
provider_svc: ProviderService,
) -> None:
assert await provider_svc.get_decrypted_token(uuid4()) is None
@@ -0,0 +1,88 @@
"""P2-9: the prompt composer injects the autogen verb table.
`compose_prompt` reads `agents/prompts/_generated/<role>.md` and
includes it as a composition layer (between role and team). This pins
that contract: when the file exists, its content appears in the
composed prompt.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.agents.factories._base import compose_prompt
from roboco.models.base import AgentRole, Team
if TYPE_CHECKING:
from pathlib import Path
def _write_layer(root: Path, rel: str, body: str) -> None:
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body)
@pytest.fixture
def fake_prompts(tmp_path: Path) -> Path:
"""Build a fake prompts directory with a known _generated/<role>.md."""
_write_layer(tmp_path, "base.md", "# BASE LAYER")
_write_layer(tmp_path, "roles/developer.md", "# ROLE LAYER (developer)")
_write_layer(tmp_path, "teams/backend.md", "# TEAM LAYER (backend)")
_write_layer(
tmp_path,
"_generated/developer.md",
"# AUTOGEN LAYER (developer)\n\n"
"## Verbs available to you (autogenerated source of truth)\n\n"
"| Verb | Body schema |\n|------|-------------|\n"
"| `give_me_work` | `give_me_work()` |\n",
)
_write_layer(tmp_path, "identities/be-dev-1.md", "# IDENTITY (be-dev-1)")
return tmp_path
def test_compose_prompt_includes_autogen_layer(fake_prompts: Path) -> None:
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
agent_slug="be-dev-1",
base_path=fake_prompts,
)
assert "AUTOGEN LAYER (developer)" in composed
assert "give_me_work()" in composed
def test_compose_prompt_orders_layers_correctly(fake_prompts: Path) -> None:
"""base → role → autogen → team → identity (composer's documented order)."""
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
agent_slug="be-dev-1",
base_path=fake_prompts,
)
base_idx = composed.index("BASE LAYER")
role_idx = composed.index("ROLE LAYER (developer)")
autogen_idx = composed.index("AUTOGEN LAYER (developer)")
team_idx = composed.index("TEAM LAYER (backend)")
identity_idx = composed.index("IDENTITY (be-dev-1)")
assert base_idx < role_idx < autogen_idx < team_idx < identity_idx
def test_compose_prompt_omits_autogen_when_file_missing(tmp_path: Path) -> None:
"""No _generated/<role>.md → composer skips that layer cleanly."""
_write_layer(tmp_path, "base.md", "# BASE")
_write_layer(tmp_path, "roles/developer.md", "# DEV ROLE")
_write_layer(tmp_path, "identities/be-dev-1.md", "# IDENTITY")
composed = compose_prompt(
role=AgentRole.DEVELOPER,
team=None,
agent_slug="be-dev-1",
base_path=tmp_path,
)
assert "BASE" in composed
assert "DEV ROLE" in composed
assert "IDENTITY" in composed
assert "AUTOGEN" not in composed # no autogen layer file
+111
View File
@@ -0,0 +1,111 @@
"""P2-7: every gateway.rejected audit row carries an attempt_id.
The attempt_id (uuid4 per rejection) lets post-mortem queries group
all attempts on a task within a window, even when multiple calls share
a correlation_id from a single inbound request.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
return ChoreographerDeps(**base)
def _is_uuid(s: str) -> bool:
try:
UUID(s)
except (ValueError, TypeError):
return False
return True
@pytest.mark.asyncio
async def test_rejection_includes_attempt_id() -> None:
"""A `not_found` rejection on i_am_done emits an audit row with attempt_id."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None # → not_found
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_am_done(aid, tid, notes="x")
assert env.error == "not_found"
audit_svc.log_event.assert_awaited()
args = audit_svc.log_event.await_args
details = args.kwargs["details"]
assert "attempt_id" in details, "P2-7: audit row must include attempt_id"
assert _is_uuid(details["attempt_id"]), "P2-7: attempt_id must be a UUID string"
@pytest.mark.asyncio
async def test_distinct_rejections_emit_distinct_attempt_ids() -> None:
"""Two rejections in sequence get different attempt_ids."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
await c.i_am_done(aid, tid, notes="x")
await c.i_am_done(aid, tid, notes="x")
expected_distinct_ids = 2
calls = audit_svc.log_event.await_args_list
ids = {call.kwargs["details"]["attempt_id"] for call in calls}
assert len(ids) == expected_distinct_ids, (
"P2-7: each rejection emits its own attempt_id"
)
@pytest.mark.asyncio
async def test_success_envelope_does_not_emit_audit() -> None:
"""Confirms the contract: audit rows fire on rejection only.
attempt_id machinery doesn't trip on success (no row to stamp).
"""
aid = uuid4()
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.give_me_work(aid)
assert env.error is None
audit_svc.log_event.assert_not_awaited()
@@ -0,0 +1,149 @@
"""P0-6 / D-13: MCP _post() surfaces envelope body on 4xx.
The pre-fix path called ``response.raise_for_status()`` then ``.json()``,
which discarded the body on any 4xx agents saw a Python
``httpx.HTTPStatusError`` traceback instead of the orchestrator's
``{error, message, remediate, missing}`` envelope. These tests pin the
fixed contract: 2xx and 4xx both return the parsed JSON; only an
unparseable body produces a synthetic ``transport_error`` envelope.
"""
from __future__ import annotations
import importlib
import json
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
import types
_MANIFEST = {
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
"flow_tools": ["give_me_work", "i_will_work_on", "i_am_done"],
"do_tools": ["commit", "note"],
"read_tools": [],
"write_tools": [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
def _seed_env(monkeypatch: pytest.MonkeyPatch) -> None:
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest_path.write_text(json.dumps(_MANIFEST))
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
def _fake_client_with(status: int, body: Any) -> MagicMock:
"""httpx.Client context-manager whose post() returns the given response."""
fake_response = MagicMock()
fake_response.status_code = status
if isinstance(body, dict):
fake_response.json.return_value = body
else:
fake_response.json.side_effect = ValueError("not json")
fake_client = MagicMock()
fake_client.__enter__ = MagicMock(return_value=fake_client)
fake_client.__exit__ = MagicMock(return_value=False)
fake_client.post.return_value = fake_response
return fake_client
@pytest.fixture
def flow_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
_seed_env(monkeypatch)
import roboco.mcp.flow_server as srv
importlib.reload(srv)
return srv
@pytest.fixture
def do_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
_seed_env(monkeypatch)
import roboco.mcp.do_server as srv
importlib.reload(srv)
return srv
def test_flow_post_returns_envelope_on_422(flow_module: types.ModuleType) -> None:
"""422 with envelope body must surface as the envelope, not raise."""
envelope_body = {
"error": "tracing_gap",
"message": "missing plan",
"remediate": "call i_will_work_on(task_id=..., plan='...')",
"missing": ["plan"],
}
client = _fake_client_with(422, envelope_body)
with patch("httpx.Client", return_value=client):
result = flow_module.give_me_work()
assert result == envelope_body
def test_flow_post_returns_envelope_on_400(flow_module: types.ModuleType) -> None:
"""400 with envelope body (e.g. role-gate rejection from do_server)."""
body = {
"error": "not_authorized",
"message": "role 'cell_pm' may not commit code",
"remediate": "PMs delegate; use delegate(...)",
"missing": [],
}
client = _fake_client_with(400, body)
with patch("httpx.Client", return_value=client):
result = flow_module.i_will_work_on("task-id", plan="x")
assert result["error"] == "not_authorized"
def test_flow_post_returns_envelope_on_404(flow_module: types.ModuleType) -> None:
"""Even 404 must surface body; only the body's content matters to the agent."""
body = {
"error": "not_found",
"message": "task abc not found",
"remediate": "call give_me_work() to find an actionable task",
"missing": [],
}
client = _fake_client_with(404, body)
with patch("httpx.Client", return_value=client):
result = flow_module.i_am_done("task-abc", notes="done")
assert result["error"] == "not_found"
def test_flow_post_synthesizes_transport_error_when_body_unparseable(
flow_module: types.ModuleType,
) -> None:
"""No JSON body → synthetic transport_error envelope (NOT a raise)."""
client = _fake_client_with(502, body=None) # body=None → ValueError on .json()
with patch("httpx.Client", return_value=client):
result = flow_module.give_me_work()
assert result["error"] == "transport_error"
assert "502" in result["message"]
assert "remediate" in result
def test_do_post_returns_envelope_on_400(do_module: types.ModuleType) -> None:
"""do_server mirrors flow_server: envelope surfaces on rejection."""
body = {
"error": "not_authorized",
"message": "role 'cell_pm' may not commit",
"remediate": "PMs delegate via delegate()",
"missing": [],
}
client = _fake_client_with(400, body)
with patch("httpx.Client", return_value=client):
result = do_module.commit("any message")
assert result["error"] == "not_authorized"
+252
View File
@@ -0,0 +1,252 @@
"""PermissionService coverage — RBAC for channels, notifications, tasks, KB.
Pure-logic checks driven by ``agents_config`` constants no DB needed.
The service is a SingletonService, so we instantiate it directly with
``object.__new__`` to bypass session-management.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from roboco.models import AgentRole, Team
from roboco.models.permissions import (
AgentContext,
KBAction,
PermissionLevel,
TaskAction,
)
from roboco.services.permissions import PermissionService
@pytest.fixture
def svc() -> PermissionService:
"""PermissionService is a SingletonService — bypass __init__ for unit tests."""
return object.__new__(PermissionService)
def _ctx(role: AgentRole, team: Team | None = None) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=team)
# ---------------------------------------------------------------------------
# Channel read access
# ---------------------------------------------------------------------------
def test_auditor_can_read_any_channel(svc: PermissionService) -> None:
"""AUDITOR has silent read on every channel."""
auditor = _ctx(AgentRole.AUDITOR)
assert svc.can_read_channel(auditor, "backend-cell")
assert svc.can_read_channel(auditor, "main-pm-board")
assert svc.can_read_channel(auditor, "any-channel-name")
def test_ceo_can_read_any_channel(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
assert svc.can_read_channel(ceo, "backend-cell")
def test_main_pm_can_read_any_channel(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_read_channel(main_pm, "backend-cell")
assert svc.can_read_channel(main_pm, "frontend-cell")
# ---------------------------------------------------------------------------
# Channel write access
# ---------------------------------------------------------------------------
def test_ceo_can_write_any_channel(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
assert svc.can_write_channel(ceo, "backend-cell")
def test_auditor_can_write_any_channel(svc: PermissionService) -> None:
"""Auditor write returns True (cover-maintenance is a convention)."""
auditor = _ctx(AgentRole.AUDITOR)
assert svc.can_write_channel(auditor, "backend-cell")
def test_main_pm_can_write_any_channel(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_write_channel(main_pm, "backend-cell")
# ---------------------------------------------------------------------------
# Channel listing
# ---------------------------------------------------------------------------
def test_get_accessible_channels_for_auditor(svc: PermissionService) -> None:
"""Auditor sees every configured channel."""
auditor = _ctx(AgentRole.AUDITOR)
channels = svc.get_accessible_channels(auditor)
assert len(channels) > 0
def test_get_writable_channels_for_ceo(svc: PermissionService) -> None:
ceo = _ctx(AgentRole.CEO)
channels = svc.get_writable_channels(ceo)
assert len(channels) > 0
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
def test_main_pm_can_send_notifications(svc: PermissionService) -> None:
main_pm = _ctx(AgentRole.MAIN_PM)
assert svc.can_send_notifications(main_pm) is True
def test_developer_cannot_send_notifications(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_send_notifications(dev) is False
def test_auditor_send_notifications_returns_bool(svc: PermissionService) -> None:
"""Auditor's notification permission is read from agents_config."""
auditor = _ctx(AgentRole.AUDITOR)
assert isinstance(svc.can_send_notifications(auditor), bool)
def test_can_notify_pm_to_dev(svc: PermissionService) -> None:
sender = _ctx(AgentRole.MAIN_PM)
recipient = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_notify(sender, recipient) is True
# ---------------------------------------------------------------------------
# Communication matrix
# ---------------------------------------------------------------------------
def test_can_communicate_within_cell(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
qa = _ctx(AgentRole.QA, team=Team.BACKEND)
assert svc.can_communicate(dev, qa) is True
def test_can_communicate_across_cells_via_pm(svc: PermissionService) -> None:
"""Communication matrix returns a bool — exact result depends on the matrix."""
main_pm = _ctx(AgentRole.MAIN_PM)
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(svc.can_communicate(main_pm, dev), bool)
# ---------------------------------------------------------------------------
# Task action permissions
# ---------------------------------------------------------------------------
def test_developer_can_claim_in_own_team(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_perform_task_action(dev, TaskAction.CLAIM, Team.BACKEND) is True
def test_qa_can_view_all(svc: PermissionService) -> None:
qa = _ctx(AgentRole.QA, team=Team.BACKEND)
# QA must be able to view tasks in their cell.
assert isinstance(
svc.can_perform_task_action(qa, TaskAction.VIEW_OWN, Team.BACKEND),
bool,
)
def test_can_perform_task_action_returns_bool(svc: PermissionService) -> None:
"""Action permission returns a bool — exact value depends on TASK_PERMISSIONS."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(
svc.can_perform_task_action(dev, TaskAction.CLOSE, Team.BACKEND), bool
)
def test_cell_pm_can_close_in_own_cell(svc: PermissionService) -> None:
cell_pm = _ctx(AgentRole.CELL_PM, team=Team.BACKEND)
assert svc.can_perform_task_action(cell_pm, TaskAction.CLOSE, Team.BACKEND) is True
def test_get_task_actions_returns_set(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
actions = svc.get_task_actions(dev)
assert hasattr(actions, "__iter__")
# ---------------------------------------------------------------------------
# Permission levels
# ---------------------------------------------------------------------------
def test_ceo_has_highest_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.CEO) == PermissionLevel.CEO
def test_developer_is_cell_member_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.DEVELOPER) == PermissionLevel.CELL_MEMBER
def test_main_pm_is_main_pm_level(svc: PermissionService) -> None:
assert svc.get_permission_level(AgentRole.MAIN_PM) == PermissionLevel.MAIN_PM
# ---------------------------------------------------------------------------
# Combined check_all
# ---------------------------------------------------------------------------
def test_check_all_returns_dict(svc: PermissionService) -> None:
"""check_all returns a permission summary dict."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
result = svc.check_all(dev)
assert isinstance(result, dict)
assert "role" in result
assert "level" in result
# ---------------------------------------------------------------------------
# Slug-based shortcuts
# ---------------------------------------------------------------------------
def test_can_agent_read_channel_known_slug(svc: PermissionService) -> None:
"""Pass a known agent slug; service should resolve role+team and decide."""
# be-dev-1 is in AGENT_ROLE_MAP as a developer in backend.
result = svc.can_agent_read_channel("be-dev-1", "backend-cell")
assert isinstance(result, bool)
def test_can_agent_read_channel_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False (deny by default)."""
assert svc.can_agent_read_channel("ghost-agent", "backend-cell") is False
def test_can_agent_send_notifications_known_slug(svc: PermissionService) -> None:
"""main-pm slug should be able to send."""
assert svc.can_agent_send_notifications("main-pm") is True
def test_can_agent_send_notifications_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False."""
assert svc.can_agent_send_notifications("ghost-agent") is False
# ---------------------------------------------------------------------------
# KB permissions
# ---------------------------------------------------------------------------
def test_get_kb_actions_returns_collection(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
actions = svc.get_kb_actions(dev)
# Returns a collection of allowed KB actions.
assert hasattr(actions, "__iter__")
def test_can_perform_kb_action_developer(svc: PermissionService) -> None:
"""KB SEARCH is generally allowed for developers."""
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(svc.can_perform_kb_action(dev, KBAction.SEARCH), bool)
Generated
+1 -23
View File
@@ -19,15 +19,6 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'",
] ]
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.18.4"
@@ -3113,7 +3104,7 @@ name = "pexpect"
version = "4.9.0" version = "4.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "ptyprocess" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [ wheels = [
@@ -4249,7 +4240,6 @@ name = "roboco"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" },
{ name = "alembic" }, { name = "alembic" },
{ name = "anthropic" }, { name = "anthropic" },
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -4314,12 +4304,10 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "types-aiofiles" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiofiles" },
{ name = "alembic" }, { name = "alembic" },
{ name = "anthropic" }, { name = "anthropic" },
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -4377,7 +4365,6 @@ dev = [
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "types-aiofiles" },
] ]
[[package]] [[package]]
@@ -5392,15 +5379,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
] ]
[[package]]
name = "types-aiofiles"
version = "25.1.0.20260409"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812, upload-time = "2026-04-09T04:22:35.308Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372, upload-time = "2026-04-09T04:22:34.328Z" },
]
[[package]] [[package]]
name = "types-cffi" name = "types-cffi"
version = "2.0.0.20260429" version = "2.0.0.20260429"
View File