mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix: agent workflow hardening (#70)
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits The commit content tool commits locally without pushing; only open_pr pushed the branch. On the first submission that was fine, but a fix committed while addressing needs_revision never reached origin (open_pr is skipped once the PR exists), so QA — which reviews the remote PR branch — re-reviewed the stale remote and re-failed the task on every cycle, a loop that never converged. i_am_done now pushes the task branch (idempotent; a no-op when nothing is unpushed) as part of the shared submit gate, covering both the normal and resume-from-verifying paths. A push failure blocks the handoff with a clear remediation rather than parking the task in awaiting_qa with commits that exist only in the developer's local workspace. * fix(orchestrator): don't reap a stale claim while the agent's container is alive The stale-claim reaper released any claimed/in_progress task whose last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain gateway calls, so a developer deep in a long edit/test cycle outran the TTL and had its claim reaped mid-work — churning the task and risking a double spawn against the still-running container. The reaper now skips a task whose assignee still holds a live (ACTIVE) agent instance, trusting container liveness — the ground truth — over the heartbeat proxy. The check is defensive on missing fields so a heartbeat-only caller (and the reaper's existing unit tests) behave exactly as before. * fix(gateway): refuse to unblock a task while a dependency is unfinished A PM unblock on a dependency-gated task moved it straight to in_progress, overriding the dependency — letting a dependent proceed without its upstream's work (e.g. a frontend task built before its UX design lands). A dependency block is meant to clear on its own via _unblock_dependents the moment the upstream reaches a terminal state. unblock now refuses while any dependency is still non-terminal, returning a clear remediation that the block resolves automatically. Manual unblock remains available for genuine, non-dependency blockers. * fix(gateway): release a dependency-blocked claim to pending instead of looping A task that reached claimed/in_progress with an unfinished dependency was left in that state when the claim guard rejected, so the orchestrator's respawn loop kept reviving its assignee — which could make no progress — burning work for nothing. The claim guard now releases such a task back to pending. claimed -> blocked is not a legal transition, so pending — held by the dispatch dependency filter — is the lifecycle-correct resting state: the respawn loop ignores pending tasks, and _unblock_dependents re-dispatches it once the upstream reaches a terminal state. release_dependency_blocked_claim shares a _force_unclaim_to_pending core with unclaim_for_reaper so both record a truthful work-session abandon reason. * feat(security): warn at startup in header-trust mode + document the auth posture When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id / X-Agent-Role headers without a signed token, so any client that can reach it may act as any role (including 'ceo'). The API now logs a clear warning at startup in this mode, and the README gains a Security section documenting the auth posture and how to harden it. Acceptable only on a trusted private network — do not expose the API to untrusted networks. * fix(workspace): scope the refresh fetch to current + default branch ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to keep every origin/<branch> ref current. On a monorepo with many accumulated feature/* branches that exceeds the refresh timeout, the fetch silently fails, and the workspace keeps a stale base — so an agent builds on an out-of-date branch. The refresh now fetches only the workspace's current branch and the repo's default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers near-nothing and can't time out. Readers need their own branch and the default; the integration branch is refreshed at branch-creation time. * fix(git): refresh a dependency-blocked task's branch off the current integration tip A cross-cell dependent (e.g. a frontend task waiting on the UX design) was branched off a base captured before its upstream merged into the integration branch, and the branch was never re-synced — so the agent built on a stale snapshot with none of the upstream's work. Two changes close the gap: - release_dependency_blocked_claim now clears branch_name, so the re-claim (after the dependency clears) re-runs branch creation. - create_branch, when the branch is already on disk with no commits of its own, resets it onto the freshly-pulled base — the dependent now builds on the current integration tip. A branch carrying real commits is left untouched, so no work is discarded; the cell->leaf cascade carries the upstream down to the dev branch automatically. * refactor(gateway): drop the sibling-sequence claim guard Sibling sequence no longer gates a claim. Cross-cell ordering is enforced by task dependencies — a cell task that depends on another is held until its upstream reaches a terminal state, a stronger, status-aware gate than the sequence-number check. That check was dormant in practice anyway: every fan-out child carries sequence 0, on which the guard short-circuited. `sequence` stays a sibling-ordering / dispatch-priority field (list_pending ordering and the panel). Removes sibling_sequence_guard and its _earlier_blocking_sibling helper, the now-unused skip_sequence parameter threaded through the claim verbs, and the sibling fetch that fed it. * feat(gateway): sort a cross-cell dependent after its upstream When the frontend cell task is wired to depend on its UX/UI sibling, set its sequence to the upstream's sequence + 1 so it sorts after the design it waits on — list_pending ordering and the panel now show UX ahead of the implementation it gates, in either delegation order. Adds TaskService.set_sequence (the sibling-ordering field is a service write; it carries no claim-gating semantics — dependencies gate claims). * feat(gateway): make the backend cell depend on UX too UX/UI design defines the screens and API contracts both implementation cells build against, so the backend cell — not just the frontend — waits on the UX/UI cell task in a product fan-out and sorts after it. Wires in either delegation order: a backend task delegated after UX gets the dependency directly; a UX task delegated after a still-pending backend sibling retro-wires it. Mirrors the existing frontend wiring (_depend_backend_on_ux and _depend_pending_backends_on_ux). Backend is held by the same dependency gate, so it costs no extra dispatch churn. * fix(websocket): forward notification acks instead of logging them incomplete The bridge handler serves both notification.sent and notification.acked, but acked events carry `agent_id` (the acking agent) rather than `recipient_id`, so every acknowledgement tripped the missing-field guard and logged "Incomplete notification event" instead of reaching the panel. Accept either field as the recipient. * feat(api): hint the full UUID when a truncated task id fails validation Agents copy the 8-character task prefix the system shows them (the commit prefix, task summaries) and send it as task_id, which fails UUID validation with an opaque "invalid length" 422 and wastes a call. The request-validation handler now detects a task_id UUID error and attaches a `remediate` hint telling the agent to retry with the full 36-character UUID from its task envelope. * fix(audit): record the blocked transition when a task is escalated Escalation sets a task to blocked by writing task.status directly, which bypassed the validated transition helper and so never emitted a task.blocked audit row — the lifecycle moved but the Auditor saw nothing. Extract the audit emit from the central transition helper into _emit_status_transition_audit and call it from the escalate path, capturing the prior status and outgoing owner before reassignment so the row is attributed correctly. * fix(docs): stop doubling the docs path so design specs index into RAG The documenter sometimes hands a doc path already rooted at docs/, and joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/..., so the file was never found and the spec never indexed — the frontend cell could not retrieve the UX design over RAG. Normalize the path before joining: trust an absolute path, otherwise strip a single redundant leading docs/ segment. * feat(security): let the control panel authenticate in secure mode With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid HMAC token, which locked the human control panel out — it sends role headers but no token. nginx, the only trusted hop between the browser and the API, now injects the CEO token on /api and /ws, so the browser never holds the signing secret. The injected value is just the existing per-agent token issued for the CEO identity (issue_panel_token), so the token-verification path is unchanged. An empty value (dev/header-trust mode) renders to no header. `make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN in .env before enabling secure mode. .env.example and the README Security section document the flow. * chore(compose): consolidate the two compose files into one docker-compose.yml and docker-compose.yaml had diverged: .yml — the file Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the /app/manifests bind-mount, while .yaml had the manifests mount but not the base URL. Merge the union into docker-compose.yml and delete the duplicate so there is one source of truth and no "multiple config files" warning. This activates the manifests mount in the deployed file: without it the orchestrator writes per-agent tool manifests to its ephemeral container fs, they never reach the host for the daemon to bind-mount, and agents fall back to all-verbs registration. Drop the stale .yaml reference from the config.py docstring, the labeler, and the CI path filters. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Renn F
parent
97533f769b
commit
06682f33c6
@@ -1231,8 +1231,8 @@ async def test_claim_review_matches_spec(role: str, status: str) -> None:
|
||||
The verb body owns dispatch via ``task.qa_claim`` (not the runner's
|
||||
claim+start chain) because the runtime semantic is "QA inspects,
|
||||
status stays at awaiting_qa" — see qa.py module docstring. The
|
||||
behavioral claim guards (already_active / paused / sibling_sequence
|
||||
skipped) run after the spec gate; they're not modelled by the spec.
|
||||
behavioral claim guards (already_active / paused / unmet_dependency)
|
||||
run after the spec gate; they're not modelled by the spec.
|
||||
"""
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
|
||||
@@ -77,13 +77,21 @@ async def fanout_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
|
||||
assigned_cell=Team.UX_UI,
|
||||
created_by=system.id,
|
||||
)
|
||||
be_project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="BE",
|
||||
slug=f"be-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/be.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=system.id,
|
||||
)
|
||||
product = ProductTable(
|
||||
id=uuid4(),
|
||||
name="Prod",
|
||||
slug=f"prod-{uuid4().hex[:6]}",
|
||||
created_by=system.id,
|
||||
)
|
||||
db_session.add_all([fe_project, ux_project, product])
|
||||
db_session.add_all([fe_project, ux_project, be_project, product])
|
||||
await db_session.flush()
|
||||
|
||||
svc = TaskService(db_session)
|
||||
@@ -105,6 +113,7 @@ async def fanout_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
|
||||
"fe_dev_id": fe_dev.id,
|
||||
"fe_project_id": fe_project.id,
|
||||
"ux_project_id": ux_project.id,
|
||||
"be_project_id": be_project.id,
|
||||
"product_id": product.id,
|
||||
}
|
||||
|
||||
@@ -224,3 +233,145 @@ async def test_dev_subtask_held_until_ux_dependency_resolves(
|
||||
assert dev_subtask.id in pending_after_ids, (
|
||||
"dev subtask must become dispatchable once UX reaches a terminal state"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependent_cell_sequence_follows_upstream_ux(
|
||||
fanout_setup: dict,
|
||||
) -> None:
|
||||
"""The frontend cell task sorts after its UX upstream: wiring the
|
||||
dependency also bumps its sequence to the UX task's sequence + 1, so
|
||||
list ordering and the panel show UX before the work it gates."""
|
||||
svc: TaskService = fanout_setup["svc"]
|
||||
tree = await _build_product_fanout(fanout_setup)
|
||||
ux_row = await svc.get(tree["ux_cell"].id)
|
||||
fe_row = await svc.get(tree["fe_cell"].id)
|
||||
assert ux_row is not None and fe_row is not None
|
||||
assert fe_row.sequence == (ux_row.sequence or 0) + 1, (
|
||||
"the dependent frontend task must sort one step after its UX upstream"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_cell_also_depends_on_ux(fanout_setup: dict) -> None:
|
||||
"""UX/UI design defines the API contracts the backend builds against, so a
|
||||
backend cell task in the same fan-out also waits on the UX cell task and
|
||||
sorts after it."""
|
||||
svc: TaskService = fanout_setup["svc"]
|
||||
choreo: Choreographer = fanout_setup["choreo"]
|
||||
tree = await _build_product_fanout(fanout_setup)
|
||||
root = tree["root"]
|
||||
ux_cell = tree["ux_cell"]
|
||||
|
||||
be_cell = await svc.create_subtask(
|
||||
TaskCreateRequest(
|
||||
title="Backend implementation for the feature",
|
||||
description="a real backend cell task description over twenty chars",
|
||||
acceptance_criteria=["endpoints satisfy the contract"],
|
||||
team=Team.BACKEND,
|
||||
created_by=fanout_setup["creator"],
|
||||
project_id=fanout_setup["be_project_id"],
|
||||
product_id=fanout_setup["product_id"],
|
||||
parent_task_id=root.id,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
)
|
||||
)
|
||||
# Forward order: the backend cell is delegated after the UX cell exists.
|
||||
await choreo._wire_ux_frontend_dependency(be_cell, root)
|
||||
await svc.session.flush()
|
||||
|
||||
be_row = await svc.get(be_cell.id)
|
||||
ux_row = await svc.get(ux_cell.id)
|
||||
assert be_row is not None and ux_row is not None
|
||||
assert ux_cell.id in be_row.dependency_ids, (
|
||||
"backend cell task must depend on the UX cell task"
|
||||
)
|
||||
assert be_row.sequence == (ux_row.sequence or 0) + 1, (
|
||||
"the backend task must sort one step after its UX upstream"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_impl_cells_retrowired_when_ux_arrives_later(
|
||||
fanout_setup: dict,
|
||||
) -> None:
|
||||
"""When the UX cell task is delegated AFTER still-pending frontend and
|
||||
backend siblings, both are retro-wired onto UX and sorted after it — the
|
||||
'either delegation order' guarantee, for both implementation cells."""
|
||||
svc: TaskService = fanout_setup["svc"]
|
||||
choreo: Choreographer = fanout_setup["choreo"]
|
||||
|
||||
root = await svc.create(
|
||||
TaskCreateRequest(
|
||||
title="Build the feature (board fan-out)",
|
||||
description="a real coordination task description over twenty chars",
|
||||
acceptance_criteria=["delegated to frontend + backend + ux_ui cells"],
|
||||
team=Team.BOARD,
|
||||
created_by=fanout_setup["creator"],
|
||||
project_id=None,
|
||||
product_id=fanout_setup["product_id"],
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.HIGH,
|
||||
)
|
||||
)
|
||||
fe_cell = await svc.create_subtask(
|
||||
TaskCreateRequest(
|
||||
title="Frontend implementation for the feature",
|
||||
description="a real frontend cell task description over twenty chars",
|
||||
acceptance_criteria=["UI matches the design"],
|
||||
team=Team.FRONTEND,
|
||||
created_by=fanout_setup["creator"],
|
||||
project_id=fanout_setup["fe_project_id"],
|
||||
product_id=fanout_setup["product_id"],
|
||||
parent_task_id=root.id,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
)
|
||||
)
|
||||
be_cell = await svc.create_subtask(
|
||||
TaskCreateRequest(
|
||||
title="Backend implementation for the feature",
|
||||
description="a real backend cell task description over twenty chars",
|
||||
acceptance_criteria=["endpoints satisfy the contract"],
|
||||
team=Team.BACKEND,
|
||||
created_by=fanout_setup["creator"],
|
||||
project_id=fanout_setup["be_project_id"],
|
||||
product_id=fanout_setup["product_id"],
|
||||
parent_task_id=root.id,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
)
|
||||
)
|
||||
# UX is delegated LAST — both pending implementation cells must be wired.
|
||||
ux_cell = await svc.create_subtask(
|
||||
TaskCreateRequest(
|
||||
title="UX/UI design for the feature",
|
||||
description="a real ux design task description over twenty chars",
|
||||
acceptance_criteria=["wireframes approved"],
|
||||
team=Team.UX_UI,
|
||||
created_by=fanout_setup["creator"],
|
||||
project_id=fanout_setup["ux_project_id"],
|
||||
product_id=fanout_setup["product_id"],
|
||||
parent_task_id=root.id,
|
||||
task_type=TaskType.DESIGN,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
)
|
||||
)
|
||||
await choreo._wire_ux_frontend_dependency(ux_cell, root)
|
||||
await svc.session.flush()
|
||||
|
||||
fe_row = await svc.get(fe_cell.id)
|
||||
be_row = await svc.get(be_cell.id)
|
||||
ux_row = await svc.get(ux_cell.id)
|
||||
assert fe_row is not None and be_row is not None and ux_row is not None
|
||||
assert ux_cell.id in fe_row.dependency_ids, "frontend must retro-wire onto UX"
|
||||
assert ux_cell.id in be_row.dependency_ids, "backend must retro-wire onto UX"
|
||||
expected_sequence = (ux_row.sequence or 0) + 1
|
||||
assert fe_row.sequence == expected_sequence
|
||||
assert be_row.sequence == expected_sequence
|
||||
|
||||
@@ -120,6 +120,10 @@ class _StubGit:
|
||||
del branch_name, actor_agent_id
|
||||
return ("ok", 0)
|
||||
|
||||
async def push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
|
||||
del agent_id, task_id
|
||||
return 0
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
branch_name: str,
|
||||
|
||||
@@ -124,6 +124,10 @@ class _StubGit:
|
||||
del branch_name, actor_agent_id
|
||||
return ("ok", 0)
|
||||
|
||||
async def push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
|
||||
del agent_id, task_id
|
||||
return 0
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
branch_name: str,
|
||||
|
||||
@@ -322,3 +322,46 @@ async def test_all_three_dev_paths_gate_then_release(dep_gate_setup: dict) -> No
|
||||
assert (
|
||||
await choreo._run_claim_guards(agent_id=fe_dev_db_id, task=released) is None
|
||||
), "claim guard must allow once UX is terminal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claimed_dependency_blocked_task_is_released_to_pending(
|
||||
dep_gate_setup: dict,
|
||||
) -> None:
|
||||
"""A CLAIMED task whose dependency is unmet is released back to pending.
|
||||
|
||||
Unlike the pre-assigned-but-pending dev subtask, a cell task can reach
|
||||
CLAIMED with an unfinished dependency (the PM claims it before the upstream
|
||||
resolves). Left claimed, the orchestrator's respawn loop churns its
|
||||
assignee. The claim guard now releases it to pending — ``claimed -> blocked``
|
||||
is not a legal transition, so pending (held by the dependency filter) is the
|
||||
lifecycle-correct resting state, and ``_unblock_dependents`` re-dispatches it
|
||||
once the upstream completes.
|
||||
"""
|
||||
svc: TaskService = dep_gate_setup["svc"]
|
||||
choreo: Choreographer = dep_gate_setup["choreo"]
|
||||
fe_dev_db_id = dep_gate_setup["fe_dev_db_id"]
|
||||
|
||||
tree = await _seed_dev_subtask_with_unmet_dep(dep_gate_setup)
|
||||
dev_subtask = tree["dev_subtask"]
|
||||
|
||||
# Force the held task to CLAIMED (the state a respawn loop churns on).
|
||||
dev_subtask.status = TaskStatus.CLAIMED
|
||||
dev_subtask.branch_name = "feature/frontend/DEVLEAF01"
|
||||
await svc.session.flush()
|
||||
|
||||
held = await svc.get(dev_subtask.id)
|
||||
guard = await choreo._run_claim_guards(agent_id=fe_dev_db_id, task=held)
|
||||
assert guard is not None, "claim guard must still reject while UX is unmet"
|
||||
assert guard.error == "invalid_state"
|
||||
|
||||
after = await svc.get(dev_subtask.id)
|
||||
assert after is not None
|
||||
assert after.status == TaskStatus.PENDING, (
|
||||
"a claimed dependency-blocked task must be released to pending"
|
||||
)
|
||||
assert after.assigned_to is None, "release clears the assignee"
|
||||
assert after.branch_name is None, (
|
||||
"release clears branch_name so the re-claim cuts fresh off the current "
|
||||
"integration tip (which by then includes the upstream's work)"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ extraction, optimal-service).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import ExitStack, asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -141,3 +141,61 @@ async def test_lifespan_handles_optimal_init_failure_gracefully() -> None:
|
||||
app = create_app()
|
||||
async with lifespan(app):
|
||||
assert app.state.optimal is None
|
||||
|
||||
|
||||
def _lifespan_io_patches() -> list:
|
||||
transcription_mock = MagicMock()
|
||||
transcription_mock.start = AsyncMock()
|
||||
transcription_mock.stop = AsyncMock()
|
||||
return [
|
||||
patch("roboco.api.app.init_db", new=AsyncMock()),
|
||||
patch("roboco.api.app.close_db", new=AsyncMock()),
|
||||
patch("roboco.api.app.TranscriptionService", return_value=transcription_mock),
|
||||
patch("roboco.api.app.ExtractionService"),
|
||||
patch("roboco.api.app.ExtractionPipeline"),
|
||||
patch(
|
||||
"roboco.api.app.get_optimal_service",
|
||||
new=AsyncMock(return_value=MagicMock()),
|
||||
),
|
||||
patch("roboco.api.app.close_optimal_service", new=AsyncMock()),
|
||||
]
|
||||
|
||||
|
||||
def _header_trust_warnings(logger_mock: MagicMock) -> list:
|
||||
return [
|
||||
c
|
||||
for c in logger_mock.warning.call_args_list
|
||||
if c.args and "HEADER-TRUST" in c.args[0]
|
||||
]
|
||||
|
||||
|
||||
async def _run_lifespan_with(*, auth_required: bool, logger_mock: MagicMock) -> None:
|
||||
"""Run the lifespan with heavy I/O patched and the auth flag forced."""
|
||||
with ExitStack() as stack:
|
||||
for cm in _lifespan_io_patches():
|
||||
stack.enter_context(cm)
|
||||
stack.enter_context(
|
||||
patch("roboco.api.app._auth_required", return_value=auth_required)
|
||||
)
|
||||
stack.enter_context(patch("roboco.api.app.logger", logger_mock))
|
||||
app = create_app()
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_warns_in_header_trust_mode() -> None:
|
||||
"""Startup warns when agent auth is not enforced (header-trust mode)."""
|
||||
logger_mock = MagicMock()
|
||||
await _run_lifespan_with(auth_required=False, logger_mock=logger_mock)
|
||||
assert _header_trust_warnings(logger_mock), (
|
||||
"header-trust startup warning expected when auth is not required"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_no_header_trust_warning_when_auth_required() -> None:
|
||||
"""No header-trust warning when ROBOCO_AGENT_AUTH_REQUIRED enforces tokens."""
|
||||
logger_mock = MagicMock()
|
||||
await _run_lifespan_with(auth_required=True, logger_mock=logger_mock)
|
||||
assert not _header_trust_warnings(logger_mock)
|
||||
|
||||
@@ -4,10 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
# UUID annotates a Pydantic model field below, so it must stay a runtime import
|
||||
# (Pydantic resolves the annotation when building the model) despite `from
|
||||
# __future__ import annotations` making it look type-checking-only to ruff.
|
||||
from uuid import UUID # noqa: TC003
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
from roboco.api.middleware import (
|
||||
_uuid_field_remediation,
|
||||
get_status_code,
|
||||
setup_middleware,
|
||||
)
|
||||
@@ -193,6 +199,62 @@ def test_generic_exception_returns_500() -> None:
|
||||
assert "error" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _uuid_field_remediation + truncated-task_id 422 remediation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uuid_field_remediation_hits_truncated_task_id() -> None:
|
||||
errors = [{"loc": ("body", "task_id"), "type": "uuid_parsing", "msg": "bad"}]
|
||||
hint = _uuid_field_remediation(errors)
|
||||
assert hint is not None
|
||||
assert "full" in hint.lower()
|
||||
assert "uuid" in hint.lower()
|
||||
|
||||
|
||||
def test_uuid_field_remediation_ignores_other_field_errors() -> None:
|
||||
errors = [{"loc": ("body", "title"), "type": "string_too_short", "msg": "x"}]
|
||||
assert _uuid_field_remediation(errors) is None
|
||||
|
||||
|
||||
def test_uuid_field_remediation_ignores_non_uuid_task_id_errors() -> None:
|
||||
errors = [{"loc": ("body", "task_id"), "type": "missing", "msg": "required"}]
|
||||
assert _uuid_field_remediation(errors) is None
|
||||
|
||||
|
||||
class _TaskIdBody(BaseModel):
|
||||
task_id: UUID
|
||||
|
||||
|
||||
def _make_uuid_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/needs-uuid")
|
||||
async def _need(body: _TaskIdBody) -> dict:
|
||||
return {"task_id": str(body.task_id)}
|
||||
|
||||
setup_middleware(app)
|
||||
return app
|
||||
|
||||
|
||||
def test_truncated_task_id_422_carries_remediation() -> None:
|
||||
"""An 8-char task_id (the recurring agent mistake) returns 422 + remediate."""
|
||||
client = TestClient(_make_uuid_app(), raise_server_exceptions=False)
|
||||
response = client.post("/needs-uuid", json={"task_id": "cee99ecc"})
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
body = response.json()
|
||||
assert "remediate" in body
|
||||
assert "full" in body["remediate"].lower()
|
||||
|
||||
|
||||
def test_other_validation_422_omits_remediation() -> None:
|
||||
"""A non-task_id validation error keeps the standard 422 shape (no remediate)."""
|
||||
client = TestClient(_make_uuid_app(), raise_server_exceptions=False)
|
||||
response = client.post("/needs-uuid", json={}) # missing task_id entirely
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert "remediate" not in response.json()
|
||||
|
||||
|
||||
def test_request_validation_handler_returns_422_with_details() -> None:
|
||||
"""request_validation_handler logs + returns 422 with errors+body (251-260)."""
|
||||
|
||||
|
||||
@@ -104,6 +104,30 @@ async def test_handle_notification_sent_broadcasts_when_connected() -> None:
|
||||
assert call_kwargs["agent_ids"] == [rid]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_notification_acked_broadcasts_using_agent_id() -> None:
|
||||
"""ACKED events carry `agent_id`, not `recipient_id`; the shared handler
|
||||
must still forward (to the acking agent) rather than log 'Incomplete
|
||||
notification event' on every acknowledgement."""
|
||||
nid = uuid4()
|
||||
aid = uuid4()
|
||||
event = _evt(
|
||||
EventType.NOTIFICATION_ACKED,
|
||||
{"notification_id": str(nid), "agent_id": str(aid), "ack_type": "read"},
|
||||
)
|
||||
bcast = AsyncMock()
|
||||
with (
|
||||
patch("roboco.api.websocket_bridge.broadcast_notification", bcast),
|
||||
patch("roboco.api.websocket_bridge.manager") as mgr,
|
||||
):
|
||||
mgr.notification_connections = {aid: {"socket-1"}}
|
||||
await _handle_notification_sent(event)
|
||||
bcast.assert_awaited_once()
|
||||
call_kwargs = bcast.await_args.kwargs
|
||||
assert call_kwargs["notification_id"] == nid
|
||||
assert call_kwargs["agent_ids"] == [aid]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_session_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""Gate Set A: claim-time guards restored from pre-gateway _helpers.py:124-204.
|
||||
|
||||
Predicates ported into Choreographer claim verbs:
|
||||
- SEQUENCE_ORDER_VIOLATION (earlier sibling must be terminal)
|
||||
- ALREADY_ACTIVE (no claim while in_progress task is open)
|
||||
- PAUSED_TASKS_EXIST (no claim while paused tasks exist)
|
||||
- PM_CANNOT_EXECUTE_CODE (cell_pm/main_pm cannot claim task_type=code)
|
||||
- ROLE_TYPED_CLAIM (developer/qa/documenter cannot cross-claim)
|
||||
|
||||
These mirror pre-gateway gates at commit 0c3d15a, file
|
||||
roboco/mcp/tasks/handlers/_helpers.py lines 124-204 plus
|
||||
roboco/mcp/tasks/handlers/claim.py:121-180 for the sibling sequence check.
|
||||
roboco/mcp/tasks/handlers/_helpers.py lines 124-204.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,21 +31,6 @@ _STEPS = [
|
||||
),
|
||||
}
|
||||
]
|
||||
# Full parity: a fresh dev claim authors the same rich plan a PM does.
|
||||
# These satisfy _dev_plan_gate (plan/approach >= 150 chars,
|
||||
# technical_considerations, risks).
|
||||
_GOOD_PLAN = (
|
||||
"Append the timestamp HTML comment to the very bottom of README.md without "
|
||||
"touching any other line, then commit it on the task branch and open a PR. "
|
||||
"Verify the diff is a single-line addition before submitting for QA."
|
||||
)
|
||||
_GOOD_TC = ["Use a trailing newline so the comment sits on its own line."]
|
||||
_GOOD_RISKS = [
|
||||
{
|
||||
"risk": "An accidental reformat of README.md balloons the diff.",
|
||||
"mitigation": "Append only; assert the diff touches one line pre-commit.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
@@ -122,138 +105,6 @@ def _task_svc_with(
|
||||
return task_svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A.1 SEQUENCE_ORDER_VIOLATION
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_work_on_blocks_when_earlier_sibling_open() -> None:
|
||||
"""Sequence=2 cannot be claimed while sequence=1 sibling is still open."""
|
||||
agent_id = uuid4()
|
||||
parent_id = uuid4()
|
||||
target_id = uuid4()
|
||||
earlier_id = uuid4()
|
||||
target = MagicMock(
|
||||
id=target_id,
|
||||
status="pending",
|
||||
plan=None,
|
||||
assigned_to=None,
|
||||
parent_task_id=parent_id,
|
||||
sequence=2,
|
||||
task_type="code",
|
||||
team="backend",
|
||||
)
|
||||
earlier = MagicMock(
|
||||
id=earlier_id,
|
||||
status="in_progress",
|
||||
sequence=1,
|
||||
title="Earlier sibling",
|
||||
)
|
||||
later = MagicMock(
|
||||
id=target_id,
|
||||
status="pending",
|
||||
sequence=2,
|
||||
)
|
||||
task_svc = _task_svc_with(target, lookups={"siblings": [earlier, later]})
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_work_on(agent_id, target_id, plan="x", steps=_STEPS)
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "sequence" in body["message"].lower()
|
||||
assert str(earlier_id) in body["remediate"]
|
||||
task_svc.claim.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_will_work_on_allows_when_earlier_sibling_terminal() -> None:
|
||||
"""Earlier siblings completed/cancelled do not block."""
|
||||
agent_id = uuid4()
|
||||
parent_id = uuid4()
|
||||
target_id = uuid4()
|
||||
target = MagicMock(
|
||||
id=target_id,
|
||||
status="pending",
|
||||
plan={"x": 1},
|
||||
assigned_to=None,
|
||||
parent_task_id=parent_id,
|
||||
sequence=2,
|
||||
task_type="code",
|
||||
team="backend",
|
||||
)
|
||||
earlier_done = MagicMock(id=uuid4(), status="completed", sequence=1)
|
||||
earlier_cancelled = MagicMock(id=uuid4(), status="cancelled", sequence=0)
|
||||
self_row = MagicMock(id=target_id, status="pending", sequence=2)
|
||||
task_svc = _task_svc_with(
|
||||
target,
|
||||
agent_id=agent_id,
|
||||
lookups={"siblings": [earlier_done, earlier_cancelled, self_row]},
|
||||
)
|
||||
task_svc.claim.return_value = MagicMock(
|
||||
id=target_id,
|
||||
status="claimed",
|
||||
plan={"x": 1},
|
||||
assigned_to=agent_id,
|
||||
task_type="code",
|
||||
)
|
||||
task_svc.start.return_value = MagicMock(
|
||||
id=target_id, status="in_progress", plan={"x": 1}, assigned_to=agent_id
|
||||
)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_work_on(
|
||||
agent_id,
|
||||
target_id,
|
||||
plan=_GOOD_PLAN,
|
||||
steps=_STEPS,
|
||||
technical_considerations=_GOOD_TC,
|
||||
risks=_GOOD_RISKS,
|
||||
)
|
||||
assert env.error is None
|
||||
task_svc.claim.assert_awaited_once_with(target_id, agent_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_task_no_sequence_check() -> None:
|
||||
"""Root tasks (no parent) skip the sequence check entirely."""
|
||||
agent_id = uuid4()
|
||||
target_id = uuid4()
|
||||
target = MagicMock(
|
||||
id=target_id,
|
||||
status="pending",
|
||||
plan={"x": 1},
|
||||
assigned_to=None,
|
||||
parent_task_id=None,
|
||||
sequence=5,
|
||||
task_type="code",
|
||||
team="backend",
|
||||
)
|
||||
task_svc = _task_svc_with(target, agent_id=agent_id)
|
||||
task_svc.claim.return_value = MagicMock(
|
||||
id=target_id, status="claimed", plan={"x": 1}, assigned_to=agent_id
|
||||
)
|
||||
task_svc.start.return_value = MagicMock(
|
||||
id=target_id, status="in_progress", plan={"x": 1}, assigned_to=agent_id
|
||||
)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_will_work_on(
|
||||
agent_id,
|
||||
target_id,
|
||||
plan=_GOOD_PLAN,
|
||||
steps=_STEPS,
|
||||
technical_considerations=_GOOD_TC,
|
||||
risks=_GOOD_RISKS,
|
||||
)
|
||||
assert env.error is None
|
||||
# Sequence check should not have queried siblings on a root task
|
||||
task_svc.get_subtasks.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A.2 ALREADY_ACTIVE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -694,3 +695,92 @@ async def test_i_am_idle_clean_returns_idle() -> None:
|
||||
env = await c.i_am_idle(agent_id)
|
||||
assert env.status == "idle"
|
||||
task_svc.mark_agent_idle.assert_awaited_once_with(agent_id)
|
||||
|
||||
|
||||
def _passing_i_am_done_task(agent_id: Any, task_id: Any) -> Any:
|
||||
"""A task that clears every i_am_done gate (so the flow reaches the push)."""
|
||||
return MagicMock(
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
assigned_to=agent_id,
|
||||
plan={"x": 1},
|
||||
branch_name="feature/backend/abc",
|
||||
work_session_id=uuid4(),
|
||||
self_verified=False,
|
||||
progress_updates=[{"message": "p"}],
|
||||
acceptance_criteria=["AC1"],
|
||||
acceptance_criteria_status=[
|
||||
{"criterion": "AC1", "referencing_artifact_id": "c1"}
|
||||
],
|
||||
commits=[{"sha": "abc"}],
|
||||
pr_number=8,
|
||||
pr_url="https://x/pr/8",
|
||||
team="backend",
|
||||
documents=[],
|
||||
dev_notes="",
|
||||
qa_notes="",
|
||||
)
|
||||
|
||||
|
||||
def _passing_i_am_done_deps(task: Any, **overrides: AsyncMock) -> ChoreographerDeps:
|
||||
"""Task + journal mocks set up so i_am_done passes through to the push."""
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = task
|
||||
task_svc.agent_for.return_value = MagicMock(
|
||||
id=task.assigned_to, role="developer", team="backend", slug=None
|
||||
)
|
||||
task_svc.submit_verification.return_value = task
|
||||
task_svc.submit_qa.return_value = task
|
||||
task_svc.submit_for_qa.return_value = task
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_reflect_for_task.return_value = True
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_learning_for_task.return_value = False
|
||||
journal_svc.has_struggle_for_task.return_value = False
|
||||
return _make_deps(task=task_svc, journal=journal_svc, **overrides)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_done_pushes_branch_before_qa_handoff() -> None:
|
||||
"""i_am_done pushes the task branch so QA reviews the latest commits.
|
||||
|
||||
A fix committed during a revision cycle is local-only until pushed; without
|
||||
this push QA re-reviews the stale remote and re-fails the task every cycle.
|
||||
"""
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
git_svc = AsyncMock()
|
||||
git_svc.push_task_branch.return_value = 1
|
||||
deps = _passing_i_am_done_deps(
|
||||
_passing_i_am_done_task(agent_id, task_id), git=git_svc
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_am_done(agent_id, task_id, "done")
|
||||
|
||||
assert env.error is None
|
||||
git_svc.push_task_branch.assert_awaited_once_with(agent_id, task_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i_am_done_blocks_when_branch_push_fails() -> None:
|
||||
"""A failed push aborts i_am_done — a task must not reach awaiting_qa with
|
||||
commits that live only in the developer's local workspace."""
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
git_svc = AsyncMock()
|
||||
git_svc.push_task_branch.side_effect = RuntimeError("fetch timed out")
|
||||
deps = _passing_i_am_done_deps(
|
||||
_passing_i_am_done_task(agent_id, task_id), git=git_svc
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.i_am_done(agent_id, task_id, "done")
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "push" in body["message"].lower()
|
||||
# The QA transition must not have run.
|
||||
deps.task.submit_qa.assert_not_awaited()
|
||||
deps.task.submit_for_qa.assert_not_awaited()
|
||||
|
||||
@@ -247,6 +247,35 @@ async def test_unblock_restore_false_returns_legacy_message() -> None:
|
||||
assert "re-engage" in body["next"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_refused_while_a_dependency_is_unfinished() -> None:
|
||||
"""A dependency block can't be force-cleared by a PM.
|
||||
|
||||
It auto-clears via _unblock_dependents once the upstream completes; manual
|
||||
unblock would let the dependent proceed without the upstream's work.
|
||||
"""
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dep_id = uuid4()
|
||||
t = MagicMock(id=task_id, status="blocked", dependency_ids=[dep_id])
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.unmet_dependency_ids.return_value = [dep_id]
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.unblock(pm_id, task_id)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "depends on" in body["message"]
|
||||
# The task must not have been advanced out of blocked.
|
||||
task_svc.unblock_with_restore.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_merges_then_completes() -> None:
|
||||
pm_id = uuid4()
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Direct unit tests for claim_guards helpers (branches only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.services.gateway.claim_guards import sibling_sequence_guard
|
||||
|
||||
|
||||
def test_sibling_sequence_guard_root_task_passes() -> None:
|
||||
"""parent_task_id None → no guard."""
|
||||
task = SimpleNamespace(id=uuid4(), parent_task_id=None, sequence=5)
|
||||
assert sibling_sequence_guard(task, []) is None
|
||||
|
||||
|
||||
def test_sibling_sequence_guard_sequence_zero_passes() -> None:
|
||||
"""sequence==0 always allowed."""
|
||||
task = SimpleNamespace(id=uuid4(), parent_task_id=uuid4(), sequence=0)
|
||||
assert sibling_sequence_guard(task, []) is None
|
||||
|
||||
|
||||
def test_sibling_sequence_guard_blocks_when_earlier_sibling_open() -> None:
|
||||
parent = uuid4()
|
||||
target = SimpleNamespace(id=uuid4(), parent_task_id=parent, sequence=2)
|
||||
earlier = SimpleNamespace(
|
||||
id=uuid4(), parent_task_id=parent, sequence=1, status="in_progress"
|
||||
)
|
||||
env = sibling_sequence_guard(target, [earlier])
|
||||
assert env is not None
|
||||
|
||||
|
||||
def test_sibling_sequence_guard_passes_when_earlier_sibling_terminal() -> None:
|
||||
parent = uuid4()
|
||||
target = SimpleNamespace(id=uuid4(), parent_task_id=parent, sequence=2)
|
||||
earlier = SimpleNamespace(
|
||||
id=uuid4(), parent_task_id=parent, sequence=1, status="completed"
|
||||
)
|
||||
assert sibling_sequence_guard(target, [earlier]) is None
|
||||
@@ -18,7 +18,9 @@ from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
from roboco.models.runtime import AgentInstance
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,3 +92,51 @@ async def test_reap_stale_claims_swallows_unclaim_errors() -> None:
|
||||
# Both stale tasks attempted; second succeeded despite first raising.
|
||||
expected_attempts = 2
|
||||
assert svc.unclaim_for_reaper.await_count == expected_attempts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_spares_claims_whose_assignee_container_is_alive() -> None:
|
||||
"""A stale-heartbeat task is NOT reaped while its assignee container lives.
|
||||
|
||||
A developer deep in a long edit/test cycle outruns the heartbeat TTL; the
|
||||
running container is the ground truth, so the claim survives rather than
|
||||
being churned out from under live work. A peer task whose assignee has no
|
||||
live instance is still reaped.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
live_id = uuid4()
|
||||
dead_id = uuid4()
|
||||
live_task = type(
|
||||
"T",
|
||||
(),
|
||||
{
|
||||
"id": live_id,
|
||||
"last_heartbeat_at": now - timedelta(seconds=600),
|
||||
"assigned_to": AGENT_UUIDS["be-dev-1"],
|
||||
"claimed_by": None,
|
||||
},
|
||||
)()
|
||||
dead_task = type(
|
||||
"T",
|
||||
(),
|
||||
{
|
||||
"id": dead_id,
|
||||
"last_heartbeat_at": now - timedelta(seconds=600),
|
||||
"assigned_to": AGENT_UUIDS["be-dev-2"],
|
||||
"claimed_by": None,
|
||||
},
|
||||
)()
|
||||
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._claim_heartbeat_ttl = 300
|
||||
orch._instances = {
|
||||
"be-dev-1": AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE)
|
||||
}
|
||||
svc = AsyncMock()
|
||||
svc.list_in_progress_or_claimed.return_value = [live_task, dead_task]
|
||||
svc.unclaim_for_reaper = AsyncMock()
|
||||
|
||||
await orch._reap_with_service(svc)
|
||||
|
||||
# The live-assignee task is spared; only the dead one is reaped.
|
||||
svc.unclaim_for_reaper.assert_awaited_once_with(dead_id)
|
||||
|
||||
@@ -12,11 +12,12 @@ stranded on a board role.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import AgentRole, TaskStatus, TaskType
|
||||
from roboco.models.base import AgentRole, TaskStatus, TaskType, Team
|
||||
from roboco.services.task import TaskService, _is_descendant_executable_task
|
||||
|
||||
|
||||
@@ -327,3 +328,44 @@ async def test_is_board_advisory_agent_classifies_roles() -> None:
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
svc = TaskService(session)
|
||||
assert await svc._is_board_advisory_agent(uuid4()) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_escalation_emits_blocked_audit_event() -> None:
|
||||
"""A non-divert escalation sets BLOCKED and MUST record a task.blocked audit
|
||||
row. The escalate path sets status directly (bypassing the validated
|
||||
transition), and used to skip the audit log entirely."""
|
||||
svc = _service()
|
||||
task = MagicMock(
|
||||
id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
task_type=TaskType.PLANNING, # not cell-executed → never diverted
|
||||
assigned_to=uuid4(),
|
||||
claimed_by=uuid4(),
|
||||
blocker_raised_by=None,
|
||||
dev_notes="",
|
||||
team=Team.BACKEND,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
)
|
||||
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
|
||||
audit_mock = MagicMock(log_task_event=AsyncMock())
|
||||
|
||||
with patch("roboco.services.audit.get_audit_service", return_value=audit_mock):
|
||||
await svc.apply_escalation(
|
||||
task=task,
|
||||
target_agent_id=uuid4(),
|
||||
escalator_slug="be-pm",
|
||||
target_slug="main-pm",
|
||||
reason="needs a decision",
|
||||
)
|
||||
# Drain the fire-and-forget audit task so the assertion sees the call.
|
||||
pending = list(svc._background_tasks)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
assert task.status == TaskStatus.BLOCKED
|
||||
audit_mock.log_task_event.assert_awaited_once()
|
||||
kwargs = audit_mock.log_task_event.await_args.kwargs
|
||||
assert kwargs["event_type"] == "task.blocked"
|
||||
assert kwargs["details"]["from_status"] == "in_progress"
|
||||
assert kwargs["details"]["to_status"] == "blocked"
|
||||
|
||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
||||
from contextlib import AbstractContextManager
|
||||
|
||||
_EXPECTED_PR_NUMBER = 7
|
||||
_PUSHED_COMMIT_COUNT = 2
|
||||
|
||||
|
||||
def _make_session(execute_returns: object | None = None) -> MagicMock:
|
||||
@@ -126,6 +127,46 @@ async def test_project_for_task_uses_project_id_when_present() -> None:
|
||||
assert out is fake_project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# push_task_branch: idempotent push at the QA-submission boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_task_branch_resolves_workspace_and_pushes() -> None:
|
||||
"""Resolves the task's project + workspace, then pushes; returns the count."""
|
||||
task = MagicMock(branch_name="feature/backend/abc")
|
||||
project = MagicMock(slug="roboco")
|
||||
svc = _service()
|
||||
_bind(svc, "_assert_task_owned_with_branch", AsyncMock(return_value=task))
|
||||
_bind(svc, "_project_for_task", AsyncMock(return_value=project))
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_assert_on_task_branch", AsyncMock())
|
||||
push_mock = AsyncMock(return_value=("feature/backend/abc", _PUSHED_COMMIT_COUNT))
|
||||
_bind(svc, "push", push_mock)
|
||||
|
||||
pushed = await svc.push_task_branch(uuid4(), uuid4())
|
||||
|
||||
assert pushed == _PUSHED_COMMIT_COUNT
|
||||
push_mock.assert_awaited_once_with(Path("/tmp/ws"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_task_branch_noop_for_project_less_task() -> None:
|
||||
"""A git-exempt task (no resolvable project) is a no-op, not an error."""
|
||||
task = MagicMock(branch_name="feature/main_pm/abc")
|
||||
svc = _service()
|
||||
_bind(svc, "_assert_task_owned_with_branch", AsyncMock(return_value=task))
|
||||
_bind(svc, "_project_for_task", AsyncMock(return_value=None))
|
||||
push_mock = AsyncMock()
|
||||
_bind(svc, "push", push_mock)
|
||||
|
||||
pushed = await svc.push_task_branch(uuid4(), uuid4())
|
||||
|
||||
assert pushed == 0
|
||||
push_mock.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# diff: derives parent + invokes git diff
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -404,3 +445,80 @@ async def test_create_branch_idempotent_when_branch_already_exists() -> None:
|
||||
|
||||
assert ["checkout", "-b", branch] in calls, "checkout -b attempted"
|
||||
assert ["checkout", branch] in calls, "fell back to existing branch on 128"
|
||||
|
||||
|
||||
def _create_branch_stubs(svc: GitService) -> None:
|
||||
object.__setattr__(svc, "_resolve_base_branch", AsyncMock(return_value="master"))
|
||||
object.__setattr__(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
|
||||
object.__setattr__(
|
||||
svc, "_checkout_base_with_fallback", AsyncMock(return_value="master")
|
||||
)
|
||||
|
||||
|
||||
async def _run_create_branch_with_existing_branch(
|
||||
svc: GitService, branch: str, unique_commits: str
|
||||
) -> list[list[str]]:
|
||||
"""Drive create_branch where `checkout -b` fails (branch exists) and the
|
||||
branch has `unique_commits` commits of its own. Returns the git argv calls.
|
||||
"""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def fake_run_git(
|
||||
_workspace: object, args: list[str], **_kw: object
|
||||
) -> object:
|
||||
calls.append(list(args))
|
||||
if list(args[:2]) == ["checkout", "-b"]:
|
||||
return MagicMock(stdout="", returncode=1) # branch already exists
|
||||
if list(args[:2]) == ["rev-list", "--count"]:
|
||||
return MagicMock(stdout=f"{unique_commits}\n", returncode=0)
|
||||
return MagicMock(stdout="", returncode=0)
|
||||
|
||||
object.__setattr__(svc, "_run_git", fake_run_git)
|
||||
with (
|
||||
patch("roboco.services.git.build_branch_name", AsyncMock(return_value=branch)),
|
||||
patch(
|
||||
"roboco.services.git.get_task_service",
|
||||
MagicMock(return_value=MagicMock(update=AsyncMock())),
|
||||
),
|
||||
):
|
||||
await svc.create_branch(
|
||||
Path("/tmp/ws"),
|
||||
"frontend",
|
||||
GitCreateBranchRequest(
|
||||
project_slug="roboco-panel",
|
||||
task_id=uuid4(),
|
||||
branch_type="feature",
|
||||
agent_id=str(uuid4()),
|
||||
parent_branch=None,
|
||||
),
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_refreshes_no_work_existing_branch_to_base() -> None:
|
||||
"""An existing branch with no commits of its own is re-pointed at the fresh
|
||||
base — a dependency-blocked task re-claimed after its upstream merged must
|
||||
not keep building on the stale snapshot."""
|
||||
svc = _service()
|
||||
_create_branch_stubs(svc)
|
||||
calls = await _run_create_branch_with_existing_branch(
|
||||
svc, "feature/frontend/abc12345--def67890", unique_commits="0"
|
||||
)
|
||||
assert ["reset", "--hard", "master"] in calls, (
|
||||
"a no-work existing branch must be reset onto the fresh base"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_keeps_existing_branch_that_has_work() -> None:
|
||||
"""An existing branch carrying its own commits is NOT reset (work preserved)."""
|
||||
svc = _service()
|
||||
_create_branch_stubs(svc)
|
||||
calls = await _run_create_branch_with_existing_branch(
|
||||
svc, "feature/frontend/abc12345--def67890", unique_commits="3"
|
||||
)
|
||||
assert not any(c[:2] == ["reset", "--hard"] for c in calls), (
|
||||
"a branch with real work must never be reset"
|
||||
)
|
||||
|
||||
@@ -679,3 +679,35 @@ async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
|
||||
task = MagicMock(branch_name=None, project_id=None, product_id=None)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
await svc._ensure_branch_for_task(task, uuid4())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_doc_abspath — normalize documenter-supplied paths under /app/docs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_doc_abspath_strips_redundant_docs_prefix() -> None:
|
||||
"""A `docs/`-rooted relative path must not double the base segment.
|
||||
|
||||
DOCS_BASE_PATH is /app/docs; joining it with `docs/design/x.md` produced
|
||||
/app/docs/docs/design/x.md, so the file was never found and never indexed.
|
||||
"""
|
||||
assert (
|
||||
TaskService._resolve_doc_abspath("docs/design/spec.md")
|
||||
== "/app/docs/design/spec.md"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_doc_abspath_keeps_plain_relative_path() -> None:
|
||||
"""A relative path with no `docs/` prefix joins under the base unchanged."""
|
||||
assert (
|
||||
TaskService._resolve_doc_abspath("design/spec.md") == "/app/docs/design/spec.md"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_doc_abspath_passes_absolute_path_through() -> None:
|
||||
"""An already-absolute path is trusted as-is (no re-rooting)."""
|
||||
assert (
|
||||
TaskService._resolve_doc_abspath("/app/docs/design/spec.md")
|
||||
== "/app/docs/design/spec.md"
|
||||
)
|
||||
|
||||
@@ -106,16 +106,65 @@ async def test_ensure_workspace_fetches_origin_on_healthy_short_circuit(
|
||||
f"Expected `git fetch origin` on healthy short-circuit, "
|
||||
f"got subprocess calls: {captured}"
|
||||
)
|
||||
# Specifically: `git fetch origin` with NO `-c` flag and no extra
|
||||
# positional refspec. The `-c` check protects the docstring's
|
||||
# Specifically: a SCOPED `git fetch --no-tags --prune origin <ref...>` with
|
||||
# NO `-c` flag. The fetch is scoped to the workspace's branches (current +
|
||||
# default) rather than all refs so it can't time out on a monorepo with many
|
||||
# accumulated feature/* branches. The `-c` check protects the docstring's
|
||||
# no-token-injection invariant — a future refactor that added
|
||||
# `git -c http.extraheader=...` would still satisfy a loose
|
||||
# `a[-2:] == ["fetch", "origin"]` assertion, silently regressing
|
||||
# the no-PAT-injection guarantee.
|
||||
# `git -c http.extraheader=...` must not slip in unnoticed.
|
||||
assert any(
|
||||
a[0] == "git" and "-c" not in a and a[-2:] == ["fetch", "origin"]
|
||||
a[0] == "git"
|
||||
and "-c" not in a
|
||||
and "fetch" in a
|
||||
and "--no-tags" in a
|
||||
and "--prune" in a
|
||||
and "origin" in a
|
||||
and a.index("origin") < len(a) - 1 # ≥1 ref after origin → scoped
|
||||
for a in fetch_calls
|
||||
), f"Expected exact `git fetch origin` (no `-c`), got: {fetch_calls}"
|
||||
), f"Expected scoped `git fetch --no-tags --prune origin <ref>`, got: {fetch_calls}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_fetch_is_scoped_to_current_and_default_branch(
|
||||
healthy_workspace: Path,
|
||||
) -> None:
|
||||
"""The refresh fetch targets only the current branch + default, not all refs.
|
||||
|
||||
An all-refs fetch times out on a monorepo with many accumulated feature/*
|
||||
branches, leaving the workspace silently stale.
|
||||
"""
|
||||
svc = _service()
|
||||
agent = _fake_agent()
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def _fake_run(
|
||||
args: list[str], **_kwargs: object
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured.append(args)
|
||||
out = ""
|
||||
if "rev-parse" in args:
|
||||
out = "feature/frontend/abc12345"
|
||||
elif "symbolic-ref" in args:
|
||||
out = "origin/master"
|
||||
return subprocess.CompletedProcess(
|
||||
args=args, returncode=0, stdout=out, stderr=""
|
||||
)
|
||||
|
||||
with (
|
||||
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
|
||||
patch("roboco.services.workspace._ensure_agent_owned"),
|
||||
):
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
|
||||
fetch = next(a for a in captured if a[0] == "git" and "fetch" in a)
|
||||
after_origin = fetch[fetch.index("origin") + 1 :]
|
||||
assert "feature/frontend/abc12345" in after_origin, (
|
||||
f"current branch must be fetched, got: {fetch}"
|
||||
)
|
||||
assert "master" in after_origin, f"default branch must be fetched, got: {fetch}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -24,8 +24,10 @@ from roboco.agents_config import (
|
||||
is_management,
|
||||
is_pm,
|
||||
issue_agent_token,
|
||||
issue_panel_token,
|
||||
verify_agent_token,
|
||||
)
|
||||
from roboco.seeds.initial_data import CEO_AGENT_ID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
@@ -295,6 +297,39 @@ def test_verify_agent_token_rejects_mismatched_signature(
|
||||
assert verify_agent_token(tok, "be-dev-1", "qa", "backend") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue_panel_token — the panel's CEO credential for secure mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_issue_panel_token_verifies_under_panel_headers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The panel token must verify under the EXACT headers the panel sends:
|
||||
X-Agent-Id = CEO uuid, X-Agent-Role = ceo, and NO team (empty)."""
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "panel-secret")
|
||||
tok = issue_panel_token()
|
||||
assert verify_agent_token(tok, CEO_AGENT_ID, "ceo", "") is True
|
||||
|
||||
|
||||
def test_issue_panel_token_unsigned_without_secret(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
|
||||
assert issue_panel_token() == "UNSIGNED"
|
||||
|
||||
|
||||
def test_panel_token_does_not_grant_other_roles_or_identities(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The panel token is bound to the CEO identity — it cannot be replayed to
|
||||
claim a different role or agent id."""
|
||||
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "panel-secret")
|
||||
tok = issue_panel_token()
|
||||
assert verify_agent_token(tok, CEO_AGENT_ID, "developer", "") is False
|
||||
assert verify_agent_token(tok, "be-dev-1", "ceo", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_pm_for_agent main_pm escalation (line 360)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user