mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
714 lines
26 KiB
Python
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"
|
|
)
|