Files
roboco/tests/unit/services/test_task.py
T
06682f33c6 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>
2026-06-05 16:35:22 +02:00

714 lines
26 KiB
Python

"""Unit tests for TaskService gateway-backfill methods.
These cover the methods the Choreographer calls into; full end-to-end
behavior is exercised by the gateway tests. Each test mocks the DB
session boundary and checks the method's contract.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import (
AgentRole,
AgentStatus,
BlockerResolverType,
TaskStatus,
Team,
)
from roboco.services.task import GatewayAgentView, TaskService
def _build_task(**overrides: object) -> MagicMock:
base: dict[str, object] = {
"id": uuid4(),
"status": TaskStatus.PENDING,
"branch_name": "feature/backend/abc12345",
"assigned_to": None,
"claimed_by": None,
"claimed_at": None,
"plan": None,
"qa_evidence_inspected": False,
"pre_block_state": None,
"pre_block_assignee": None,
"pre_block_metadata": None,
"blocker_resolver_type": None,
"blocker_raised_by": None,
"commits": [],
"dev_notes": None,
}
base.update(overrides)
return MagicMock(**base)
def _service_with(execute_returns: object) -> TaskService:
"""Build a TaskService whose session.execute returns `execute_returns`."""
session = MagicMock()
session.execute = AsyncMock(return_value=execute_returns)
session.flush = AsyncMock()
return TaskService(session)
def _bind(svc: TaskService, name: str, value: object) -> None:
"""Stub `name` on `svc` without tripping mypy's method-assign check."""
object.__setattr__(svc, name, value)
# ---------------------------------------------------------------------------
# Aliases / thin wrappers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_blocked_for_team_filters_by_team() -> None:
svc = TaskService(MagicMock())
list_blocked_mock = AsyncMock(return_value=[MagicMock(id="t1")])
_bind(svc, "list_blocked", list_blocked_mock)
out = await svc.list_blocked_for_team(Team.BACKEND)
list_blocked_mock.assert_awaited_once_with(team=Team.BACKEND)
assert len(out) == 1
@pytest.mark.asyncio
async def test_list_blocked_all_teams_passes_no_team() -> None:
svc = TaskService(MagicMock())
list_blocked_mock = AsyncMock(return_value=[])
_bind(svc, "list_blocked", list_blocked_mock)
await svc.list_blocked_all_teams()
list_blocked_mock.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_list_awaiting_pm_review_for_team_passes_team() -> None:
svc = TaskService(MagicMock())
list_pm_mock = AsyncMock(return_value=[])
_bind(svc, "list_awaiting_pm_review", list_pm_mock)
await svc.list_awaiting_pm_review_for_team(Team.FRONTEND)
list_pm_mock.assert_awaited_once_with(team=Team.FRONTEND)
@pytest.mark.asyncio
async def test_submit_verification_records_progress_when_notes_given() -> None:
svc = TaskService(MagicMock())
add_progress_mock = AsyncMock()
submit_for_verification_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "add_progress", add_progress_mock)
_bind(svc, "submit_for_verification", submit_for_verification_mock)
agent_id = uuid4()
task_id = uuid4()
await svc.submit_verification(agent_id, task_id, "implemented login")
add_progress_mock.assert_awaited_once_with(task_id, agent_id, "implemented login")
submit_for_verification_mock.assert_awaited_once_with(
task_id, agent_role="developer"
)
@pytest.mark.asyncio
async def test_submit_verification_skips_progress_when_notes_empty() -> None:
svc = TaskService(MagicMock())
add_progress_mock = AsyncMock()
_bind(svc, "add_progress", add_progress_mock)
_bind(svc, "submit_for_verification", AsyncMock(return_value=MagicMock()))
await svc.submit_verification(uuid4(), uuid4(), "")
add_progress_mock.assert_not_called()
@pytest.mark.asyncio
async def test_submit_qa_records_progress_when_notes_given() -> None:
svc = TaskService(MagicMock())
add_progress_mock = AsyncMock()
submit_for_qa_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "add_progress", add_progress_mock)
_bind(svc, "submit_for_qa", submit_for_qa_mock)
agent_id = uuid4()
task_id = uuid4()
await svc.submit_qa(agent_id, task_id, "ready for review")
add_progress_mock.assert_awaited_once()
submit_for_qa_mock.assert_awaited_once_with(task_id, agent_role="developer")
# ---------------------------------------------------------------------------
# list_assigned_for_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_assigned_for_agent_returns_active_tasks() -> None:
expected_tasks = [MagicMock(id="t1"), MagicMock(id="t2")]
scalars = MagicMock()
scalars.all.return_value = expected_tasks
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_assigned_for_agent(uuid4())
assert out == expected_tasks
# ---------------------------------------------------------------------------
# agent_for
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_for_returns_view_with_role_team_skills(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_agent = MagicMock(
id=uuid4(), slug="be-pm", role=AgentRole.CELL_PM, team=Team.BACKEND
)
result = MagicMock()
result.scalar_one_or_none.return_value = fake_agent
svc = _service_with(result)
monkeypatch.setattr(
"roboco.agents_config.get_escalation_target", lambda _slug: "main_pm"
)
monkeypatch.setattr(
"roboco.agents_config.get_agent_skills",
lambda _slug: [{"id": "task_management"}],
)
view = await svc.agent_for(uuid4())
assert isinstance(view, GatewayAgentView)
assert view.role == "cell_pm"
assert view.team == "backend"
assert view.escalation_target == "main_pm"
assert view.skills == [{"id": "task_management"}]
@pytest.mark.asyncio
async def test_agent_for_returns_none_when_missing() -> None:
result = MagicMock()
result.scalar_one_or_none.return_value = None
svc = _service_with(result)
assert await svc.agent_for(uuid4()) is None
# ---------------------------------------------------------------------------
# qa/documenter/cell_pm for_team
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_qa_agent_for_team_finds_qa() -> None:
qa = MagicMock(id=uuid4(), role=AgentRole.QA, team=Team.BACKEND)
scalars = MagicMock()
scalars.first.return_value = qa
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.qa_agent_for_team(Team.BACKEND)
assert out is qa
@pytest.mark.asyncio
async def test_documenter_for_team_returns_none_when_missing() -> None:
scalars = MagicMock()
scalars.first.return_value = None
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.documenter_for_team(Team.BACKEND) is None
@pytest.mark.asyncio
async def test_cell_pm_for_team_finds_pm() -> None:
pm = MagicMock(id=uuid4(), role=AgentRole.CELL_PM, team=Team.UX_UI)
scalars = MagicMock()
scalars.first.return_value = pm
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.cell_pm_for_team(Team.UX_UI) is pm
# ---------------------------------------------------------------------------
# get_active_task_for_agent + list_paused_for_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_active_task_for_agent_returns_top_task() -> None:
task = MagicMock(id=uuid4(), status=TaskStatus.IN_PROGRESS)
result = MagicMock()
result.scalar_one_or_none.return_value = task
svc = _service_with(result)
assert await svc.get_active_task_for_agent(uuid4()) is task
@pytest.mark.asyncio
async def test_list_paused_for_agent_returns_paused_tasks() -> None:
paused = [MagicMock(id=uuid4(), status=TaskStatus.PAUSED)]
scalars = MagicMock()
scalars.all.return_value = paused
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_paused_for_agent(uuid4())
assert out == paused
# ---------------------------------------------------------------------------
# list_awaiting_main_pm_all
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_awaiting_main_pm_all_returns_root_tasks() -> None:
roots = [MagicMock(id=uuid4(), parent_task_id=None)]
scalars = MagicMock()
scalars.all.return_value = roots
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_awaiting_main_pm_all()
assert out == roots
# ---------------------------------------------------------------------------
# all_subtasks_terminal
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_all_subtasks_terminal_true_when_all_completed() -> None:
scalars = MagicMock()
scalars.all.return_value = [TaskStatus.COMPLETED, TaskStatus.CANCELLED]
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.all_subtasks_terminal(uuid4()) is True
@pytest.mark.asyncio
async def test_all_subtasks_terminal_false_when_one_active() -> None:
scalars = MagicMock()
scalars.all.return_value = [TaskStatus.COMPLETED, TaskStatus.IN_PROGRESS]
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.all_subtasks_terminal(uuid4()) is False
@pytest.mark.asyncio
async def test_all_subtasks_terminal_true_when_no_subtasks() -> None:
scalars = MagicMock()
scalars.all.return_value = []
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.all_subtasks_terminal(uuid4()) is True
# ---------------------------------------------------------------------------
# set_plan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_plan_wraps_string_into_text_dict() -> None:
task = _build_task()
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.set_plan(task.id, "do the thing")
assert task.plan == {"text": "do the thing"}
assert out is task
@pytest.mark.asyncio
async def test_set_plan_passes_dict_through() -> None:
task = _build_task()
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.set_plan(task.id, {"steps": ["a", "b"]})
assert task.plan == {"steps": ["a", "b"]}
assert out is task
@pytest.mark.asyncio
async def test_set_plan_returns_none_when_task_missing() -> None:
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=None))
assert await svc.set_plan(uuid4(), "plan") is None
# ---------------------------------------------------------------------------
# mark_evidence_inspected + mark_agent_idle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_evidence_inspected_sets_flag() -> None:
task = _build_task(qa_evidence_inspected=False)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
await svc.mark_evidence_inspected(task.id)
assert task.qa_evidence_inspected is True
@pytest.mark.asyncio
async def test_mark_evidence_inspected_no_op_on_missing_task() -> None:
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=None))
await svc.mark_evidence_inspected(uuid4()) # must not raise
@pytest.mark.asyncio
async def test_reassign_sets_assigned_to_and_claimed_by() -> None:
task = _build_task(assigned_to=None, claimed_by=None)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
new_assignee = uuid4()
out = await svc.reassign(task.id, new_assignee)
assert out is task
assert task.assigned_to == new_assignee
assert task.claimed_by == new_assignee
@pytest.mark.asyncio
async def test_reassign_clears_assignment_when_none() -> None:
prev = uuid4()
task = _build_task(assigned_to=prev, claimed_by=prev)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.reassign(task.id, None)
assert out is task
assert task.assigned_to is None
assert task.claimed_by is None
@pytest.mark.asyncio
async def test_reassign_returns_none_when_task_missing() -> None:
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=None))
out = await svc.reassign(uuid4(), uuid4())
assert out is None
@pytest.mark.asyncio
async def test_mark_agent_idle_sets_status_idle() -> None:
agent = MagicMock(id=uuid4(), status=AgentStatus.ACTIVE)
result = MagicMock()
result.scalar_one_or_none.return_value = agent
svc = _service_with(result)
await svc.mark_agent_idle(agent.id)
assert agent.status == AgentStatus.IDLE
# ---------------------------------------------------------------------------
# qa_claim / doc_claim
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_qa_claim_sets_assignment_on_awaiting_qa() -> None:
task = _build_task(status=TaskStatus.AWAITING_QA)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
qa_id = uuid4()
out = await svc.qa_claim(qa_id, task.id)
assert out is task
assert task.assigned_to == qa_id
assert task.claimed_by == qa_id
assert isinstance(task.claimed_at, datetime)
@pytest.mark.asyncio
async def test_qa_claim_rejects_wrong_status() -> None:
task = _build_task(status=TaskStatus.IN_PROGRESS)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.qa_claim(uuid4(), task.id)
assert out is None
@pytest.mark.asyncio
async def test_doc_claim_sets_assignment_on_awaiting_documentation() -> None:
task = _build_task(status=TaskStatus.AWAITING_DOCUMENTATION)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
doc_id = uuid4()
out = await svc.doc_claim(doc_id, task.id)
assert out is task
assert task.assigned_to == doc_id
# ---------------------------------------------------------------------------
# qa_pass / qa_fail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_qa_pass_delegates_to_pass_qa() -> None:
qa_id = uuid4()
task_id = uuid4()
task = _build_task(id=task_id, claimed_by=qa_id)
svc = TaskService(MagicMock(flush=AsyncMock()))
pass_qa_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "pass_qa", pass_qa_mock)
await svc.qa_pass(qa_id, task_id, "looks good")
pass_qa_mock.assert_awaited_once_with(task_id, notes="looks good", agent_role="qa")
# active_claimant_id cleared so the documenter can claim cleanly.
assert task.active_claimant_id is None
@pytest.mark.asyncio
async def test_qa_fail_appends_issues_to_dev_notes() -> None:
qa_id = uuid4()
task = _build_task(dev_notes=None, claimed_by=qa_id)
svc = TaskService(MagicMock(flush=AsyncMock()))
fail_qa_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "fail_qa", fail_qa_mock)
issues = ["missing test", "no docstring"]
await svc.qa_fail(qa_id, task.id, "blocking", issues)
assert task.dev_notes is not None
assert "missing test" in task.dev_notes
assert "no docstring" in task.dev_notes
fail_qa_mock.assert_awaited_once_with(task.id, notes="blocking", agent_role="qa")
assert task.active_claimant_id is None
# ---------------------------------------------------------------------------
# unblock_with_restore
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_with_restore_returns_to_pre_block_state() -> None:
pre_assignee = uuid4()
task = _build_task(
status=TaskStatus.BLOCKED,
pre_block_state="in_progress",
pre_block_assignee=pre_assignee,
pre_block_metadata={"foo": "bar"},
blocker_resolver_type=BlockerResolverType.AGENT,
blocker_raised_by=pre_assignee,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.unblock_with_restore(uuid4(), task.id, restore=True)
assert out is task
assert task.status == TaskStatus.IN_PROGRESS
assert task.assigned_to == pre_assignee
assert task.pre_block_state is None
assert task.pre_block_assignee is None
@pytest.mark.asyncio
async def test_unblock_with_restore_falls_through_when_no_snapshot() -> None:
task = _build_task(status=TaskStatus.BLOCKED, pre_block_state=None)
svc = TaskService(MagicMock(flush=AsyncMock()))
unblock_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "unblock", unblock_mock)
out = await svc.unblock_with_restore(uuid4(), task.id, restore=True)
unblock_mock.assert_awaited_once()
assert out is task
@pytest.mark.asyncio
async def test_unblock_with_restore_calls_legacy_unblock_when_restore_false() -> None:
task = _build_task(
status=TaskStatus.BLOCKED, pre_block_state=TaskStatus.IN_PROGRESS.value
)
svc = TaskService(MagicMock(flush=AsyncMock()))
unblock_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "unblock", unblock_mock)
await svc.unblock_with_restore(uuid4(), task.id, restore=False)
unblock_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_unblock_no_branch_returns_to_pending() -> None:
# A task blocked before it was ever claimed (a dependency-gated claim that
# got escalated) has no branch — unblock must return it to pending, not a
# branchless in_progress the dispatcher refuses to spawn (spawn-loop).
raiser = uuid4()
task = _build_task(
status=TaskStatus.BLOCKED, branch_name=None, blocker_raised_by=raiser
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
assert out is task
assert task.status == TaskStatus.PENDING
assert task.assigned_to == raiser
@pytest.mark.asyncio
async def test_unblock_with_branch_resumes_in_progress() -> None:
# A task claimed (has a branch) before it blocked resumes in_progress.
task = _build_task(
status=TaskStatus.BLOCKED,
branch_name="feature/backend/abc12345",
blocker_raised_by=uuid4(),
)
svc = TaskService(MagicMock(flush=AsyncMock()))
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
assert out is task
assert task.status == TaskStatus.IN_PROGRESS
# ---------------------------------------------------------------------------
# cell_pm_complete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_appends_merge_commit() -> None:
task = _build_task(commits=[{"hash": "old", "message": "earlier"}])
svc = TaskService(MagicMock(flush=AsyncMock()))
complete_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "complete", complete_mock)
pm_id = uuid4()
await svc.cell_pm_complete(pm_id, task.id, "all good", merge_commit="deadbeef")
assert task.commits[-1]["hash"] == "deadbeef"
assert task.commits[-1]["kind"] == "merge"
complete_mock.assert_awaited_once_with(task.id, agent_id=pm_id)
@pytest.mark.asyncio
async def test_cell_pm_complete_skips_merge_when_none() -> None:
task = _build_task(commits=[])
svc = TaskService(MagicMock(flush=AsyncMock()))
complete_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "complete", complete_mock)
await svc.cell_pm_complete(uuid4(), task.id, "all good", merge_commit=None)
assert task.commits == []
# ---------------------------------------------------------------------------
# escalate / escalate_up_to_role
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_returns_none_when_no_target_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task = _build_task()
agent = MagicMock(id=uuid4(), slug="lone-agent")
agent_result = MagicMock()
agent_result.scalar_one_or_none.return_value = agent
session = MagicMock()
session.execute = AsyncMock(return_value=agent_result)
session.flush = AsyncMock()
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
monkeypatch.setattr(
"roboco.agents_config.get_escalation_target", lambda _slug: None
)
out = await svc.escalate(uuid4(), task.id, "stuck")
assert out is None
@pytest.mark.asyncio
async def test_escalate_up_to_role_returns_none_for_unknown_role() -> None:
task = _build_task()
agent = MagicMock(id=uuid4(), slug="some-agent")
agent_result = MagicMock()
agent_result.scalar_one_or_none.return_value = agent
session = MagicMock()
session.execute = AsyncMock(return_value=agent_result)
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.escalate_up_to_role(uuid4(), task.id, "bogus_role", "reason")
assert out is None
# ---------------------------------------------------------------------------
# _ensure_branch_for_task — coordination/fan-out tasks do no git
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_branch_returns_existing_branch() -> None:
"""An already-branched task short-circuits before any project check."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name="feature/backend/abc12345", project_id=None)
assert (
await svc._ensure_branch_for_task(task, uuid4()) == "feature/backend/abc12345"
)
@pytest.mark.asyncio
async def test_ensure_branch_coordination_root_cuts_integration_branch() -> None:
"""A product-backed root cuts feature/main_pm/{root} in each product repo."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name=None, project_id=None, product_id=uuid4())
create_in_project = AsyncMock(return_value="feature/main_pm/root1234")
_bind(svc, "_create_branch_in_project", create_in_project)
product_svc = MagicMock(distinct_project_ids=AsyncMock(return_value=[uuid4()]))
project_svc = MagicMock(get=AsyncMock(return_value=MagicMock()))
with (
patch("roboco.services.product.get_product_service", return_value=product_svc),
patch("roboco.services.project.get_project_service", return_value=project_svc),
):
result = await svc._ensure_branch_for_task(task, uuid4())
assert result == "feature/main_pm/root1234"
create_in_project.assert_awaited_once()
@pytest.mark.asyncio
async def test_ensure_branch_coordination_root_no_cell_map_stays_branchless() -> None:
"""A product with no cell->repo map yet stays branchless (graceful fallback)."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name=None, project_id=None, product_id=uuid4())
product_svc = MagicMock(distinct_project_ids=AsyncMock(return_value=[]))
with patch("roboco.services.product.get_product_service", return_value=product_svc):
result = await svc._ensure_branch_for_task(task, uuid4())
assert result == ""
@pytest.mark.asyncio
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
"""A task with neither a project nor a product is genuinely misconfigured."""
svc = TaskService(MagicMock())
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"
)