Files
roboco/tests/unit/runtime/test_blocker_and_claimed_dispatch.py
T
f58fd4530e Fix: human surface lifecycle hardening (#81)
* fix: harden agent-idle, redis loop, git errors, escalation audit

- i_am_idle no longer 500s when auto-pausing a task whose commits are
  stored as dicts: tolerate dict-or-object commit refs and run the
  synthetic-checkpoint computation inside the swallowing try block.
- The stream event loop no longer logs an idle redis read-timeout as an
  ERROR every cycle; the blocking-read timeout is treated as a normal idle.
- Git command failures surface git's own (secret-scrubbed) stderr in the
  error message instead of a bare 'Command failed', so push/fetch
  rejections are diagnosable; the injected PAT is redacted.
- The escalate-to-pool redirect emits the task.pending audit event,
  closing a status mutation that previously skipped the audit log.

* fix: let privileged operators set task status via an audited override

The task update route silently dropped a 'status' field in the request body,
so a CEO/admin could not transition a task wedged in a state with no valid
in-band move (e.g. a blocked task whose work merged out-of-band) — the panel
returned 200 while nothing changed. Add 'status' to the update schema and
apply it through a new audited 'admin_set_status' that bypasses the strict
transition validator but always records the audit event. The override
requires elevated permissions; ordinary field updates are unchanged.

* fix: stop human chat sessions from expiring between messages

Messaging sessions fell back to a hardcoded 300s idle timeout, shorter than a
normal pause in a human conversation: the sweeper closed the session and the
next message opened a new one, so a person could not hold a continuous chat.
Make the idle timeout configurable (session_idle_timeout_seconds, default
3600) and resolve an unset timeout to it at every session-creation path
instead of the 300s column fallback.

* fix: resolve doubled doc paths and stop the indexer warning flood

The doc-path resolver returned absolute paths verbatim, so a documenter path
that doubled the base segment (/app/docs/docs/...) never resolved on disk and
the docs never indexed into RAG. Reduce an absolute path under the docs base
to a relative one before normalizing, leaving truly-external absolute paths
for the indexer to skip. The indexer now skips non-markdown source files and
logs a missing/non-doc source at debug instead of warning on every pass.

* fix: reject project repo URLs that point at a protected repository

Add a configurable denylist (protected_git_urls) enforced in the project
create and update paths, so a project cannot be registered against a
repository that must not receive agent commits or merges (e.g. the roboco
source repo during a smoke run). Empty by default (no behavior change);
operators set it to sandbox smoke-test projects.

* fix: let an agent release a blocked task back to the pool

A developer (or QA/doc) trapped on a 'blocked' task had no legal forward
move — every verb rejected from that state — so the dispatcher kept
respawning it with nothing to do. Allow 'unclaim' to release a blocked task
the agent owns back to pending (assignment cleared, work session abandoned,
audited), so the cell PM can re-delegate it instead of the agent churning.

* fix: keep blocked-dev churn out and cell tasks out of board hands

- The dispatcher no longer respawns the owner of a blocked task: from blocked
  the owner has no legal move, so respawning only churns; it is revived on
  unblock or released via unclaim.
- Escalation no longer hands a cell (backend/frontend/ux_ui) coordination task
  to a board/advisory role — such an escalation is diverted to the cell pool,
  matching the existing executable-task guard. main_pm targets are unaffected.

* chore: add an opt-in full clean-slate to the reset script

FULL_RESET=1 wipes everything under the roboco data root except the
persistent service stores (ollama/postgres/redis) and clears the persisted
agent Claude session dirs (ROBOCO_CLAUDE_STATE_DIRS), which otherwise replay
across runs. Default off — the existing DB/Redis wipe + workspace git-reset
is unchanged.

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-08 01:59:51 +02:00

317 lines
11 KiB
Python

"""Dispatch routing for blocked tasks (#17) and agentless claims (#19).
#17: a blocked task reassigned to Main PM must dispatch THAT assignee to
unblock it, not the ex-assignee cell PM — the pre-unblock note is assignee-only
and the ex-assignee got not_authorized, livelocking the respawn.
#19: a task left claimed/in_progress with an assignee but no running container
is invisibly stuck (only PENDING tasks get fresh dispatch). The orchestrator
must (re)spawn the assignee after a short grace window, or release the claim to
pending when the assignee is unknown.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
from roboco.seeds.initial_data import AGENT_UUIDS
def _orch() -> AgentOrchestrator:
orch = object.__new__(AgentOrchestrator)
orch._instances = {}
return orch
def _active_instance(agent_id: str) -> AgentInstance:
return AgentInstance(agent_id=agent_id, state=AgentState.ACTIVE)
# ---------------------------------------------------------------------------
# _blocker_resolver_slug (#17)
# ---------------------------------------------------------------------------
def test_blocked_task_assigned_to_main_pm_dispatches_main_pm() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["main-pm"],
}
# The current assignee (Main PM) holds unblock authority — dispatch THEM,
# not the ex-assignee cell PM (be-pm), which would loop on not_authorized.
assert orch._blocker_resolver_slug(task) == "main-pm"
def test_blocked_task_assigned_to_board_dispatches_board() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["product-owner"],
}
assert orch._blocker_resolver_slug(task) == "product-owner"
def test_blocked_task_held_by_dev_falls_back_to_cell_pm() -> None:
orch = _orch()
# A dev raised i_am_blocked and still holds the task → cell PM resolves.
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["be-dev-1"],
}
assert orch._blocker_resolver_slug(task) == "be-pm"
def test_blocked_task_unassigned_falls_back_to_cell_pm() -> None:
orch = _orch()
task: dict[str, Any] = {"id": "t1", "team": "frontend", "assigned_to": None}
assert orch._blocker_resolver_slug(task) == "fe-pm"
def test_blocked_task_non_cell_team_unassigned_is_unroutable() -> None:
orch = _orch()
task: dict[str, Any] = {"id": "t1", "team": "board", "assigned_to": None}
assert orch._blocker_resolver_slug(task) is None
# ---------------------------------------------------------------------------
# _claimed_task_needs_agent — claimed-but-no-agent detection
# ---------------------------------------------------------------------------
_STALE = (datetime.now(UTC) - timedelta(minutes=30)).isoformat()
def test_claimed_task_with_no_agent_past_grace_returns_assignee() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) == "be-dev-1"
def test_claimed_task_with_active_agent_is_healthy() -> None:
orch = _orch()
orch._instances["be-dev-1"] = _active_instance("be-dev-1")
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_claimed_task_within_grace_window_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
# Compute "fresh" at test time, not module load: the grace check uses
# wall-clock now(), so a module-level constant ages out of the window
# during a long full-suite run and flakes this assertion.
"updated_at": datetime.now(UTC).isoformat(),
}
# Fresh claim — spawn may still be in flight; do not churn.
assert orch._claimed_task_needs_agent(task) is None
def test_claimed_task_without_assignee_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": None,
"claimed_by": None,
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_hitl_blocked_claimed_task_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "blocked",
"blocker_resolver_type": "human",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_in_progress_task_with_no_agent_returns_assignee() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "in_progress",
"assigned_to": AGENT_UUIDS["fe-dev-2"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) == "fe-dev-2"
# ---------------------------------------------------------------------------
# _get_prompt_for_agent — role-appropriate respawn prompt (#19)
# ---------------------------------------------------------------------------
#
# A respawn must hand each role the prompt it can act on. The bug: the PM/board
# branch fell through to the developer prompt, telling a PM/board agent to write
# code and call verbs it does not own.
def _task(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "t1",
"title": "T",
"status": "in_progress",
"team": "backend",
}
base.update(over)
return base
@pytest.mark.parametrize(
("agent_slug", "marker"),
[
("be-dev-1", "development task"),
("be-qa", "ready for QA review"),
("be-doc", "ready for documentation"),
("be-pm", "PM for backend team"),
("main-pm", "MAIN PM at RoboCo"),
("product-owner", "You are on the Board"),
("auditor", "AUDIT"),
],
)
def test_get_prompt_for_agent_routes_by_role(agent_slug: str, marker: str) -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent(agent_slug, _task())
assert marker in prompt
def test_get_prompt_for_pm_is_not_the_dev_prompt() -> None:
# Regression for #19: a respawned PM must NOT receive the developer prompt.
orch = _orch()
pm_prompt = orch._get_prompt_for_agent("be-pm", _task())
assert "development task" not in pm_prompt
assert "You do NOT code" in pm_prompt
def test_get_prompt_for_board_is_not_the_dev_prompt() -> None:
orch = _orch()
board_prompt = orch._get_prompt_for_agent("product-owner", _task())
assert "development task" not in board_prompt
assert "do NOT build, code" in board_prompt
def test_head_marketing_prompt_is_marketing_on_marketing_team() -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="marketing"))
assert "marketing task" in prompt
def test_head_marketing_prompt_is_board_off_marketing_team() -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="backend"))
assert "You are on the Board" in prompt
# ---------------------------------------------------------------------------
# _dispatch_claimed_without_agent — one-spawn-per-tick throttle (#19)
# ---------------------------------------------------------------------------
#
# `monkeypatch.setattr` is used to stub instance methods because direct
# attribute assignment (`orch.spawn_agent = ...`) trips mypy's method-assign
# check; the fixture is the type-safe, suppression-free way to do it.
def _stub_git_context(orch: AgentOrchestrator, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orch, "_task_git_context", lambda _task: None)
@pytest.mark.asyncio
async def test_dispatch_claimed_without_agent_spawns_at_most_one_per_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = _orch()
orch._tick_handled_tasks = set()
stale_tasks = [
{"id": f"t{i}", "status": "claimed", "assigned_to": AGENT_UUIDS["be-dev-1"]}
for i in range(3)
]
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=stale_tasks))
monkeypatch.setattr(orch, "_claimed_task_needs_agent", lambda _task: "be-dev-1")
_stub_git_context(orch, monkeypatch)
spawn = AsyncMock()
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._dispatch_claimed_without_agent(client=MagicMock())
# Three agentless claims, but only ONE container spawned this tick.
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_dispatch_claimed_without_agent_releases_unknown_without_spending_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The release-to-pending path spawns nothing and must NOT consume the
# per-tick spawn budget — it keeps draining stale unknown claims, then
# spawns the first task with a known assignee.
orch = _orch()
orch._tick_handled_tasks = set()
tasks = [
{"id": "u1", "status": "claimed", "assigned_to": "ghost-uuid"},
{"id": "u2", "status": "claimed", "assigned_to": "ghost-uuid"},
{"id": "k1", "status": "claimed", "assigned_to": AGENT_UUIDS["be-dev-1"]},
]
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=tasks))
def _needs(task: dict[str, Any]) -> str:
return orch._resolve_agent_slug(str(task["assigned_to"]))
monkeypatch.setattr(orch, "_claimed_task_needs_agent", _needs)
_stub_git_context(orch, monkeypatch)
release = AsyncMock()
monkeypatch.setattr(orch, "_release_claim_to_pending", release)
spawn = AsyncMock()
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._dispatch_claimed_without_agent(client=MagicMock())
expected_releases = 2 # both ghost claims released
assert release.await_count == expected_releases
spawn.assert_awaited_once() # then one known assignee respawned
@pytest.mark.asyncio
async def test_handle_dev_existing_owner_skips_blocked() -> None:
"""A blocked task's owner is not respawned — it has no legal move from
blocked, so respawning it only churns; it waits for unblock or release."""
orch = _orch()
orch._respawn_dev_if_inactive = AsyncMock()
orch._is_agent_active = MagicMock(return_value=False)
await orch._handle_dev_existing_owner({"id": "t1"}, "blocked", "be-dev-1")
orch._respawn_dev_if_inactive.assert_not_called()
@pytest.mark.asyncio
async def test_handle_dev_existing_owner_respawns_in_progress() -> None:
"""An in_progress task whose owner is inactive is still respawned."""
orch = _orch()
orch._respawn_dev_if_inactive = AsyncMock()
orch._is_agent_active = MagicMock(return_value=False)
await orch._handle_dev_existing_owner({"id": "t1"}, "in_progress", "be-dev-1")
orch._respawn_dev_if_inactive.assert_awaited_once()