mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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> * fix: read the real commit key (hash) and audit the restore-unblock path - The auto-pause checkpoint and _extract_first_commit_sha read commit dicts by key 'sha', but persisted commits are keyed 'hash' (CommitRef.hash) — the prior change stopped the crash but silently dropped every ref. Read 'hash' (sha fallback) at both sites; the test now uses the production dict shape so the regression can't hide. - unblock_with_restore set status directly and skipped the audit log; emit the status-transition audit there too, like the other direct-set paths. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
317 lines
11 KiB
Python
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()
|