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:
Renzo F
2026-06-05 16:35:22 +02:00
committed by GitHub
co-authored by Renn F
parent 97533f769b
commit 06682f33c6
40 changed files with 1300 additions and 653 deletions
@@ -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)"
)
+59 -1
View File
@@ -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)
+62
View File
@@ -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)."""
+24
View File
@@ -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
+51 -1
View File
@@ -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"
+118
View File
@@ -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"
)
+32
View File
@@ -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"
)
+56 -7
View File
@@ -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
+35
View File
@@ -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)
# ---------------------------------------------------------------------------