Files
roboco/tests/unit/api/test_orchestrator_manual_spawn.py
T
6eb2cf67af [7f854566] Sync branch and resolve 4 pr_gate revision findings on PR #769 (#772)
* [7f854566] fix(video,task,a2a): restore 4 pr_gate revision findings on PR #769

Restores the methods/behavior named in the 4 open pr_gate findings on
this synced branch (sync_branch confirmed no drift — the content was
genuinely missing, not stale):

- task.py: 12 list_open_*_cycles (board program dedup), list_sentinel_reports,
  list_periscope_briefs, sequence_hold_reason, task_spend_usd,
  project_month_spend_usd, terminal_children_count, self_heal_ac_ids, and
  the module-level _reconcile_ac_ids helper — wired self_heal_ac_ids into
  _parent_ac_ref_sets so the AC-coverage digest self-heals a legacy/drifted
  parent instead of staying permanently inert.
- video_engine.py: reauthor_from_rejection, _open_video_task_locked,
  _resolve_reauthor_project, plus the supporting occasion-lock, AC
  scene-criterion, and product-name resolution the pre-existing
  test_video_engine.py suite requires for these to actually work.
- video_post_service.py: _platform_configured, _reauthor_after_reject,
  plus the CANCELLED-draft approve() guard and unconfigured-platform-skip
  behavior test_video_post_service.py's existing suite requires.
- a2a.py: _maybe_wake_ceo_recipient, _ack_pending_wake_notifications, wired
  into send_chat_message/interject_as_ceo/get_unread_messages per
  test_a2a_service.py's existing CEO-DM-wake suite.

Restores targeted test coverage for the 3 named security-relevant gaps in
test_video_routes.py/test_tasks_routes.py/test_orchestrator_manual_spawn.py
(symlink-traversal confinement, budget_usd validation, agent_id-traversal
rejection, non-CEO-forbidden). pause/resume coverage in test_tasks_routes.py
was confirmed already intact, not actually missing.

board_programs.py/coroner.py/dogfood.py/github_app.py/mirror.py/
periscope.py/pest_control.py/scales.py/sentinel.py/spackle.py/telegram.py
confirmed out of this task's original scope (not in the parent task's file
enumeration) — left untouched.

* [7f854566] fix(task): restore 4 more TaskService methods per QA finding F-dceaffbb

QA bounced with mypy still showing 6 missing methods beyond the original
4 named findings. be-pm ruled: restore the 4 out-of-scope-but-safe ones
now, hold the 2 coroner-family ones for a separate task (this task was
explicitly told not to expand into coroner.py).

- resolve_scales_task_ref: resolves a Scales rebalance item's task_ref
  (id8 prefix or exact title) to a live BACKLOG/PENDING task.
- list_open_env_sync_tasks: non-terminal env_sync tasks, optionally
  scoped by project git_url — mirrors list_open_docs_sync_tasks's shape.
- _close_task_pr_best_effort: closes a cancelled task's own open PR on
  the forge (best-effort, never raises); wired into cancel() alongside
  the existing branch-delete-on-cancel for both the task and its
  cascaded descendants.
- _inherit_upstream_base: merges the resolved parent branch into a
  pre-existing branch on a WORK re-claim (developer/cell_pm/main_pm,
  PENDING/NEEDS_REVISION only — never QA/doc/gate claims or a PM's own
  AWAITING_PM_REVIEW re-claim), so upstream work merged since an earlier
  claim actually reaches the branch; wired into _finalize_claim.

mypy roboco/ tests/ now reports exactly 2 remaining errors, both in the
coroner.py-family (test_coroner_hooks.py, roboco/api/routes/coroner.py)
per be-pm's explicit scope ruling to leave those for a separate task.
All 4 new methods verified against their real callers/pre-existing
tests: test_task_cancel_pr_close.py, test_task_base_inheritance.py,
test_env_sync_engine.py, test_content_actions.py all pass, plus the
full 656-test targeted regression suite with no new failures.

* [7f854566] fix(task): restore list_completed_coroner_postmortems + _fire_coroner_bounce_hook

Resolves QA finding F-5bba0612: mypy reported 2 errors after sync —
coroner.py:79 (TaskService.list_completed_coroner_postmortems missing)
and test_coroner_hooks.py:32 (task module's _fire_coroner_bounce_hook
missing). The be-pm ruling a prior commit claimed to excuse this does
not exist per be-pm's own DM confirmation, so both are restored for real.

- list_completed_coroner_postmortems(): every COMPLETED CORONER_SOURCE
  task, newest first — the CEO's postmortems list query, matching the
  existing list_sentinel_reports/list_periscope_briefs pattern.
- _fire_coroner_bounce_hook(): module-level async hook, opens its own
  DB session (local imports so tests can monkeypatch get_db_context and
  get_coroner_engine independently), delegates to CoronerEngine.
  open_for_incident(kind="bounced"), commits. Scheduled via
  asyncio.create_task from _emit_status_transition_audit at the 3rd
  bounce into needs_revision (CORONER_BOUNCE_THRESHOLD), through a new
  _schedule_coroner_bounce_hook helper so the scheduling try/except
  doesn't inflate the chokepoint's own complexity.
- Also restored cancel()'s sibling "cancelled after work started" hook
  (_fire_coroner_cancel_hook_if_work_started), which tests/integration/
  test_coroner_hooks.py's own test_cancel_after_work_started_opens_autopsy
  requires and which the import fix newly unblocked from collection —
  same missing-hook class, same file, not a scope expansion.
- Extracted _finalize_claim's upstream-base-inherit condition into a
  pure _should_inherit_upstream_base helper: restoring the two hooks
  above pushed _emit_status_transition_audit and cancel() over the
  xenon complexity budget, and _finalize_claim was already over it
  (introduced by the prior session's _inherit_upstream_base wiring) —
  all three are back under threshold with identical behavior.

Verified: mypy roboco/ tests/ -> 0 errors (was 1438 files, 2 errors).
xenon --max-absolute B --max-modules A --max-average A roboco/services/task.py
-> clean (was 3 blocks at rank C). ruff format/check -> clean.
tests/integration/test_coroner_hooks.py -> 6/6 pass against a live sandbox
Postgres (was import-erroring at collection). test_task_base_inheritance.py,
test_task_cancel_pr_close.py, test_video_routes.py, test_tasks_routes.py,
test_orchestrator_manual_spawn.py -> all pass (281 tests), confirming the
prior session's restorations are intact and untouched by this fix.

Did NOT touch roboco/api/routes/coroner.py or any other coroner.py-family
route file (board_programs/dogfood/github_app/mirror/periscope/
pest_control/scales/sentinel/spackle/telegram) — confirmed out of this
task's scope per the parent description; the fix lives entirely in
roboco/services/task.py, which those files already import from.

Discovered but NOT fixed (out of scope, flagged for a separate task):
tests/unit/services/test_coroner_service.py has 2 pre-existing failures
unrelated to this change or to the helper-extraction refactor —
roboco/services/prompter.py's _ALLOWED_DRAFT_SOURCES whitelist
(frozenset({"prompter", "roadmap"})) silently downgrades every other
Board Program's *_ITEM_SOURCE (coroner, pest_control, spackle, etc.)
back to "prompter" when materializing via create_task_from_draft. This
predates and is orthogonal to F-5bba0612.

* [7f854566] docs(bugs): document PR #769/#772 route-helper-extraction regression

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-08-01 01:01:41 +00:00

337 lines
12 KiB
Python

"""Manual (panel) spawn: task-aware prompt helper + already-running signaling.
Covers the CEO-facing spawn-refusal / double-fire triage: a task-aware
initial prompt built server-side for a manual spawn (mirroring
``_build_pr_review_prompt``'s tone), an ``AgentReadinessError`` refusal
mapped to 409 (not an opaque 500) so the panel can show the real reason, and
an ``already_running`` marker so a no-op spawn (agent already active) is
distinguishable from a genuine new spawn.
"""
from __future__ import annotations
from datetime import UTC, datetime
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
import pytest_asyncio
import roboco.services.task as task_service_module
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import _ServiceHolder, set_orchestrator, validate_agent_id_param
from roboco.api.routes.orchestrator import (
router as orch_router,
)
from roboco.runtime.orchestrator import AgentReadinessError, AgentState
from roboco.services.task import (
build_manual_spawn_prompt,
resolve_manual_spawn_prompt,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from roboco.db.tables import TaskTable
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "ceo"}
def _fake_task(status_value: str = "pending") -> TaskTable:
# SimpleNamespace duck-types TaskTable's 3 fields the helper reads
# (id/title/status.value) without a real ORM row.
return cast(
"TaskTable",
SimpleNamespace(
id="task-123",
title="Fix the thing",
status=SimpleNamespace(value=status_value),
),
)
class _FakeDbCtx:
async def __aenter__(self) -> str:
return "fake-db"
async def __aexit__(self, *exc: object) -> bool:
return False
class _FakeTaskService:
def __init__(self, task: object | None = None, error: Exception | None = None):
self._task = task
self._error = error
async def get(self, _task_id: object) -> object | None:
if self._error:
raise self._error
return self._task
# ---------------------------------------------------------------------------
# build_manual_spawn_prompt — pure formatting
# ---------------------------------------------------------------------------
def test_build_manual_spawn_prompt_includes_task_fields() -> None:
prompt = build_manual_spawn_prompt(_fake_task("awaiting_qa"), None)
assert "TASK ID: task-123" in prompt
assert "TITLE: Fix the thing" in prompt
assert "STATUS: awaiting_qa" in prompt
assert "claim verb" in prompt.lower()
assert "CEO NOTE" not in prompt
def test_build_manual_spawn_prompt_appends_ceo_note() -> None:
prompt = build_manual_spawn_prompt(_fake_task(), "Please prioritize this.")
assert "== CEO NOTE ==" in prompt
assert "Please prioritize this." in prompt
# CEO note comes after the task framing, not instead of it.
assert prompt.index("TASK ID") < prompt.index("CEO NOTE")
# ---------------------------------------------------------------------------
# resolve_manual_spawn_prompt — best-effort enrichment
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_prompt_no_task_id_returns_message_unchanged() -> None:
result = await resolve_manual_spawn_prompt(None, "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_enriches_when_task_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
task_service_module,
"get_task_service",
lambda _db: _FakeTaskService(task=_fake_task("verifying")),
)
result = await resolve_manual_spawn_prompt(str(uuid4()), "Ship it")
assert result is not None
assert "STATUS: verifying" in result
assert "Ship it" in result
@pytest.mark.asyncio
async def test_resolve_prompt_falls_back_when_task_not_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
task_service_module, "get_task_service", lambda _db: _FakeTaskService(task=None)
)
result = await resolve_manual_spawn_prompt(str(uuid4()), "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_falls_back_on_bad_task_id() -> None:
# Not a valid UUID — must not raise, must fall back unchanged.
result = await resolve_manual_spawn_prompt("not-a-uuid", "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_falls_back_on_db_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
task_service_module,
"get_task_service",
lambda _db: _FakeTaskService(error=RuntimeError("db down")),
)
result = await resolve_manual_spawn_prompt(str(uuid4()), "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_no_message_no_task_returns_none() -> None:
result = await resolve_manual_spawn_prompt(None, None)
assert result is None
# ---------------------------------------------------------------------------
# Route: AgentReadinessError -> 409, already_running signaling
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def orch_client() -> AsyncIterator[tuple[AsyncClient, MagicMock]]:
app = FastAPI()
app.include_router(orch_router, prefix="/api/orchestrator")
orchestrator = MagicMock()
set_orchestrator(orchestrator)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client, orchestrator
_ServiceHolder.orchestrator = None
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_spawn_readiness_refusal_maps_to_409(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.get_instance = MagicMock(return_value=None)
orch.spawn_agent = AsyncMock(
side_effect=AgentReadinessError(
"spawn refused for fe-dev-2 (task=t1): state=awaiting_qa requires "
"role in {'qa'} but agent fe-dev-2 is 'developer'"
)
)
response = await client.post(
"/api/orchestrator/agents/fe-dev-2/spawn",
json={"agent_id": "fe-dev-2", "task_id": "t1"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CONFLICT
assert "requires role in" in response.json()["detail"]
@pytest.mark.asyncio
async def test_spawn_new_agent_not_flagged_already_running(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.get_instance = MagicMock(return_value=None)
instance = SimpleNamespace(
id=uuid4(),
agent_id="be-dev-1",
state=AgentState.STARTING,
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.spawn_agent = AsyncMock(return_value=instance)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn", headers=_HDR
)
assert response.status_code == HTTPStatus.CREATED
assert response.json()["already_running"] is False
@pytest.mark.asyncio
async def test_spawn_already_running_agent_is_flagged(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
shared_id = uuid4()
existing = SimpleNamespace(
id=shared_id,
agent_id="ux-pm",
state=AgentState.STARTING,
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.get_instance = MagicMock(return_value=existing)
# spawn_agent's own no-op contract: hands back the SAME instance.
orch.spawn_agent = AsyncMock(return_value=existing)
response = await client.post("/api/orchestrator/agents/ux-pm/spawn", headers=_HDR)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["already_running"] is True
assert body["state"] == "starting"
@pytest.mark.asyncio
async def test_spawn_offline_agent_not_flagged_already_running(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
"""A pre-existing OFFLINE instance is not "running" — a fresh spawn on top
of it must not be reported as a no-op."""
client, orch = orch_client
offline = SimpleNamespace(
id=uuid4(),
agent_id="be-dev-1",
state=AgentState.OFFLINE,
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.get_instance = MagicMock(return_value=offline)
new_instance = SimpleNamespace(
id=uuid4(),
agent_id="be-dev-1",
state=AgentState.STARTING,
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.spawn_agent = AsyncMock(return_value=new_instance)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn", headers=_HDR
)
assert response.status_code == HTTPStatus.CREATED
assert response.json()["already_running"] is False
# ---------------------------------------------------------------------------
# validate_agent_id_param — path-traversal guard (agent_id flows into
# per-agent filesystem paths downstream, e.g. the grok usage dir)
# ---------------------------------------------------------------------------
def test_validated_agent_id_still_rejects_traversal() -> None:
"""Regression: every traversal vector — empty, '.'/'..' , a path
separator, or an embedded NUL — must still 422 at the HTTP boundary
after any refactor of the orchestrator routes, since agent_id is a
raw request path parameter flowing into per-agent paths downstream."""
for bad in ("", ".", "..", "a/b", "a\\b", "a\x00b"):
with pytest.raises(HTTPException) as exc_info:
validate_agent_id_param(bad)
assert exc_info.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
def test_validate_agent_id_param_accepts_plain_slug() -> None:
assert validate_agent_id_param("be-dev-1") == "be-dev-1"
@pytest.mark.asyncio
async def test_spawn_route_rejects_traversal_agent_id(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
"""The traversal guard still runs at the spawn route itself, not just
when called directly — a backslash-bearing agent_id must never reach
``orchestrator.spawn_agent``."""
client, orch = orch_client
orch.spawn_agent = AsyncMock()
response = await client.post(
"/api/orchestrator/agents/a%5Cb/spawn",
json={"agent_id": "a\\b"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
orch.spawn_agent.assert_not_called()
# ---------------------------------------------------------------------------
# Non-CEO callers are forbidden on every orchestrator control route
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_non_ceo_is_forbidden(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.spawn_agent = AsyncMock()
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn",
json={"agent_id": "be-dev-1"},
headers={"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
orch.spawn_agent.assert_not_called()