feat(board): Board Programs — the complete twelve-program catalog (Phases 1-3) (#699)

* feat(board): Pest Control — the first project-scoped Board Program

The Product Owner hunts latent defects (what the org records but nobody
reads): a weekly cycle — accelerated off-schedule when the trailing-7-day
rework rate crosses pest_rework_threshold, with the cheap dedup/scope gates
evaluated before the metrics queries — opens one held exploration task
against the least-recently-explored opted-in project (deterministic
round-robin; opted_in_projects gains a stable ORDER BY), with server-
assembled evidence in the spawn prompt (rework hotspots, recurring-findings
and waived-minor ledger aggregates, all capped) plus prior-cycle LEARN
context. The PO calls the new PO-only propose_bug_hunt verb once: ≤5 items,
evidence required per item, targets validated against pest_control
participation. CEO decides per item — approve materializes a BACKLOG task
(source pest_control, never auto-starts), reject records the reason; both
feed the LEARN ledger by exploration task id; all-terminal completes the
cycle. Telegram queue pushes carry working Approve/Reject handlers
mirroring the roadmap kind. Doctrine: board.md Pest Control section +
product-owner verb entry + regenerated verb tables.

* feat(panel): Pest Control review queue

Command Center gains the pest review queue (per-item approve/reject with
reason, mirroring the roadmap queue); the Programs card and the project
settings participates-in checkboxes pick the new program up registry-driven
— the settings section renders for the first time now that a project-scoped
program exists.

* feat(board): Periscope — HoM market-research brief program

Weekly org-scoped cycle: a solo HoM spawn researches the market (web
research with mandatory source URLs — uncited findings are rejected) and
files one structured brief via the new HoM-only propose_market_brief verb:
headline, cited findings, threats/opportunities, positioning note, all
soup-checked and screened through the injection guard at persist time
(web-derived text later reaches prompts; flags recorded, content never
dropped). A brief is a report, not a proposal: the verb completes the
exploration in the same call (the x_feature asymmetry), the cycle ledger
auto-closes, and the CEO gets a best-effort notification with no
approve/reject surface (periscope deliberately never joins Telegram's
action kinds). The latest brief is injected into the roadmap exploration
prompt — Periscope feeds Printer, the first cross-role program input.

* feat(panel): Market Briefs tab (read-only)

Business page gains a Market Briefs tab listing Periscope briefs —
headline, cited findings, threats/opportunities — read-only by design; a
report has nothing to approve.

* feat(board): Coroner — event-triggered Auditor postmortems

The first EVENT program: no cron — three best-effort hooks open an autopsy
when a task bounces to its 3rd revision (the audit chokepoint), is
cancelled after work started, or is budget-blocked; all gated on arming +
one-open-autopsy dedup, none can fail the underlying transition. A solo
Auditor spawn reads the incident (server-assembled findings + transition
context) and files one propose_postmortem: incident summary, root cause,
failed stage (validated against the real status vocabulary), and ONE
process change — a playbook-kind change drafts via PlaybookService
directly into the normal pending-curation queue; the briefed draft_playbook
manifest grant was deliberately NOT added, preserving the existing
'auditor curates but never drafts' invariant test. Complete-at-propose
(report asymmetry), cycle ledger auto-closes, CEO notified link-only.
Integrated as a union with Periscope across the shared program surfaces.

* feat(panel): Coroner postmortems card

Read-only postmortems list under Business → Programs — incident, root
cause, failed stage, process change; nothing to approve, the process-change
artifact (a draft playbook) rides the existing curation queue.

* feat(board): Sentinel — Auditor drift-watch quality reports

Weekly org-scoped cycle: a solo Auditor spawn receives a server-assembled
drift context (waived-findings trend, open findings by severity,
conventions-violation hotspots, top spend — all capped, pure ORM) and files
one propose_quality_report: headline, 1-7 area-validated items with
evidence and suggested actions, overall assessment. Report semantics —
complete-at-propose, cycle auto-closes, CEO notified display-only (never on
Telegram's approve/reject surface); items are structured so a later
convert-to-task control is cheap. Integration adopts Sentinel's module-
level dict-dispatch for board-program routing (xenon-driven), folding all
prior programs in; app router mounting extracted to a helper for the same
budget.

* feat(panel): Quality Reports tab (read-only)

Business page gains the Sentinel quality-reports tab — headline, per-area
observations with evidence and suggested actions; read-only, a report has
nothing to approve.

* feat(board): Spackle — gap-fill audit program

Biweekly project-scoped PO cycle over the half-shipped surface area: API
routes without panel surfaces (and vice versa), armed flags without docs,
docs promises the code doesn't keep, dead-end tabs — the inventory diffing
is the PO's own read-tool work, ordered by the spawn prompt with file:line
citations required; the server injects only prior-cycle LEARN and the
rotation target. Rotation is now a shared module-level helper
(pick_rotation_target, parameterized by source) both project-scoped
engines use — pest_control delegates to it, behavior-identical, with a
cross-pollution test proving the two programs' rotations stay independent.
propose_gap_fill mirrors the bug-hunt verb (≤5 items, two-sided evidence
required, participation gate); per-item CEO decide materializes BACKLOG
source=spackle tasks; full Telegram kind incl. approve/reject handlers.
All seven program routers now mount from one helper.

* feat(panel): Spackle gap-fill review queue

Command Center gains the gap-fill queue mirroring the pest-control one —
per-item approve/reject with the two-sided gap evidence rendered.

* feat(board): Scales — monthly portfolio rebalance

Org-scoped PO cycle over the stale backlog: the spawn receives a capped
stale-task snapshot (BACKLOG/PENDING unclaimed >30 days) plus the charter
and prior-cycle LEARN, and files one propose_rebalance — 1-7 items, each a
resolvable task_ref with action reprioritize (validated new priority) or
cancel, rationale required. Per-item CEO decide: approve EXECUTES the
action (audited priority update, or the normal cancel path) — the first
program whose materializer mutates existing tasks instead of creating
them; reject records the reason; LEARN by exploration task id;
all-terminal completes the cycle. Full Telegram decide-kind wiring.
Integrated as the eight-program union (registry, dict dispatch, routers
helper, teardown enumerations).

* feat(panel): Scales rebalance review queue

Command Center gains the rebalance queue — per-item approve/reject with
the action, target task, and rationale rendered.

* feat(board): Mirror — quarterly positioning audit

Project-scoped HoM cycle over messaging surfaces: README claims vs shipped
reality, docs-site promises vs code, charter alignment — the audit is the
HoM's own read-tool work with citations required; the server injects the
charter, prior-cycle LEARN, and the shared rotation target. propose_
messaging_fixes mirrors the gap-fill verb (≤5 items, drift evidence naming
claim + contradicting reality, participation gate); per-item CEO decide
materializes BACKLOG source=mirror documentation tasks; full Telegram
decide-kind wiring. Nine-program union across the shared surfaces.

* feat(panel): Mirror messaging-fixes review queue

* feat(board): Megaphone — HoM standing editorial calendar

Cron cycle (3 days, org-scoped, gated on X credentials — drafting content
nobody can post is pointless): the HoM receives a shipped-this-week digest
plus Unreleased changelog bullets and files one propose_editorial_post
(angle-validated, ≤280, brand voice) that materializes a held x_editorial
draft through the SAME X-queue origination chokepoint release posts use —
zero new approval surface, notifications and CEO decide for free.
Complete-at-propose; cycle auto-closes. Ten-program union.

* feat(panel): x_editorial source labels in the X queue surfaces

* feat(board): Librarian — proactive playbook mining

Biweekly org-scoped Auditor cycle: mines recurring non-private learning
journals (≥2-count grouping with a recency fallback) against the existing
playbook-title inventory and files one propose_playbook_drafts — 1-3
drafts, each with the repeated-pattern evidence that justifies it,
duplicate titles rejected in-batch and against the live store. Drafts are
created via PlaybookService directly (the Coroner precedent — the
'auditor curates but never drafts' do-verb invariant stays intact and
tested) and land in the normal pending-curation queue the Auditor's own
triage already surfaces; no new panel surface. Complete-at-propose;
display-only CEO notification. Eleven-program union.

* feat(board): War Room — release campaign planning

EVENT program with a REAL originator (unlike coroner's stub): a release
publish hooks a campaign brief beside the release-post seam, and the CEO's
run-now originates on demand — the cron loop never fires it. The HoM
designs a 2-6 post arc (teaser → launch → follow-up → spotlight; 280-cap,
future strictly-ascending publish_after, stage vocabulary) and one
propose_campaign call materializes each post as a held x_campaign draft
through the X-queue chokepoint. V1 is manual-cadence by design: publish_
after renders as queue guidance and the CEO approves each post at its
moment — nothing auto-posts, ever; the auto-schedule upgrade is a
documented ceiling. Twelve-program union: full registry complete.

* feat(panel): x_campaign labels + publish-after guidance in the X queue

* feat(board): Barfly — adjacent-conversation replies

Cron cycle (2 days, org-scoped, X-credentials gated): the engine searches
X for conversations where RoboCo is relevant but unmentioned (new OAuth-
signed search_recent on the client; queries + candidate cap configurable),
screens every fetched tweet through the injection guard (stored unclamped
— a clamp was truncating the candidate under the envelope, caught by the
dev's own tests), dedupes via the existing x_seen_mentions ledger (no
migration; also prevents double-drafting against the mentions poll), and
opens one held HoM exploration carrying the screened candidates. propose_
conversation_replies enforces candidate-id-only replies (≤5, 280-cap);
each materializes a held x_barfly draft through the X-queue chokepoint,
threaded via a new in_reply_to seam on post_tweet that only x_barfly
drafts use. The X redraft machinery is now dict-dispatch over per-source
extractors with reply-ref carry for x_barfly. Thirteen-program registry.
War Room's test fakes gained the new abstract search_recent stub.

* feat(board): Dogfood — the PO walks the product

The fourteenth and final registry entry, completing the catalog. EVENT
program (release-publish hook beside the war-room hook + CEO run-now, both
through the same real originator; the cron loop never fires it), project-
scoped with shared rotation. The permission surface is the careful part:
the PO's dogfood spawn — and ONLY that spawn — gets the Playwright MCP
mounted, via a task-scoped fail-closed probe mirroring the video-authoring
precedent (a PO spawned for roadmap/pest/scales never sees browser tools;
tested both ways); the PM agent image bakes chromium unconditionally like
the ux image, the mount stays task-gated in code. The walk targets the
rotation target's live surfaces (panel_base_url only when the target is
the org's own project, honest degradation otherwise); propose_friction_
fixes files ≤5 walked-path-evidenced items; per-item CEO decide
materializes BACKLOG source=dogfood tasks; full Telegram decide kind.
Also: megaphone/librarian/war_room arming keys restored to the settings
validator — their panel toggles would have been rejected (dropped in
earlier unions; the same silent-arming class the drill killed once
already).

* feat(panel): Dogfood friction review queue

* chore(board): final whole-branch sweep fixes

The night's closing adversarial pass over the integrated fourteen-program
registry found ONE functional defect — the war-room test fakes' post_tweet
predated Barfly's in_reply_to_tweet_id kwarg (LSP violation, the only red
in an otherwise fully green gate) — plus doc/test drift, all fixed: the
source-parity test completes to fourteen (spackle/mirror were silently
absent while its neighboring comment claimed full coverage), the PO
identity doc gains its missing Dogfood verb, the auditor quick-list gains
propose_postmortem, three stale comments corrected (rotation docstring,
panel registry header, X source enumerations), the dogfood release-hook
gains the exception-swallow test its four sibling hooks already had, and
the CHANGELOG's Unreleased section documents the whole Board Programs
train. Full make quality: exit 0, all gates green.

* docs: full documentation sweep for the Board Programs train

CLAUDE.md's roadmap-engine entry superseded by the Board Program registry
entry (all fourteen programs, arming, scoping, LEARN, guardrails) with the
role verb tables and playwright row refreshed; docs/rag gains the agent-
facing architecture doc plus full propose_* call-shape sections in the
three board role docs, and corrects the strategy-engine section to shipped
reality (only idle→roadmap is wired); docs/map covers the registry + all
twelve engines with flags, gotchas, and drift notes. The 0.27.0 reference
inventory confirmed only the release-executor's canonical set carries the
version — left for the 0.28.0 cut.

* feat(board): human titles + descriptions on every program surface

Raw registry keys rendered as bare panel labels — an operator reading
x_feature had no idea what enabling or running it does. The registry
dataclass gains title/description (test-enforced non-empty for every
entry, unique titles), the API passes them through, and every surface
renders title-with-description-tooltip instead of the key: the Programs
card (label, toggle hint, run-now toast), and the project settings
participates-in/excluded-from checkboxes.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-25 17:13:32 +02:00
committed by GitHub
co-authored by Renn F
parent e77c3b7a63
commit 401f8a2cc9
201 changed files with 38811 additions and 202 deletions
+209
View File
@@ -0,0 +1,209 @@
"""Barfly exploration dispatch — Head-of-Marketing-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_periscope_dispatch.py (both are complete-at-propose)."""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import BARFLY_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _barfly_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Barfly conversation-reply cycle",
"description": "Review the screened candidates and draft replies.",
"assigned_to": "head-marketing",
"source": BARFLY_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": None,
}
@pytest.mark.asyncio
async def test_barfly_dispatch_spawns_only_head_marketing() -> None:
"""A barfly exploration task must spawn the Head of Marketing alone —
the Product Owner is not part of this cycle."""
orch = _make_orch()
task = _barfly_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_barfly_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_barfly_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _barfly_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_barfly_exploration(task)
await orch._dispatch_barfly_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_barfly_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _barfly_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_barfly_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_barfly_source_away_from_board() -> None:
"""A board_barfly task must ride the dedicated barfly dispatcher, never
the two-reviewer ``_handle_board_assigned_task``, nor the periscope or
feature-spotlight dispatchers, nor plain PM handling."""
task = _barfly_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._dispatch_pest_control_exploration = AsyncMock()
stub._dispatch_periscope_exploration = AsyncMock()
stub._dispatch_barfly_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_barfly_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._dispatch_feature_spotlight_exploration.assert_not_awaited()
stub._dispatch_periscope_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_barfly_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_barfly_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_barfly_prompt_names_real_verbs() -> None:
"""The prompt must steer HoM to its real verbs (triage /
propose_conversation_replies / i_am_idle)."""
orch = _make_orch()
prompt = orch._build_barfly_prompt(_barfly_task())
assert "triage()" in prompt
assert "propose_conversation_replies(" in prompt
assert "i_am_idle()" in prompt
def test_barfly_prompt_names_never_invent_a_tweet() -> None:
orch = _make_orch()
prompt = orch._build_barfly_prompt(_barfly_task())
assert "never invent a tweet" in prompt.lower()
def test_barfly_prompt_renders_candidates() -> None:
orch = _make_orch()
task = _barfly_task(
orchestration_markers={
"barfly_candidates": [
{
"id": "111",
"author_handle": "someone",
"text": "we should build a multi-agent org",
"engagement_note": "3 combined likes/replies/retweets",
}
]
}
)
prompt = orch._build_barfly_prompt(task)
assert "id=111" in prompt
assert "we should build a multi-agent org" in prompt
def test_barfly_prompt_renders_none_when_no_candidates() -> None:
orch = _make_orch()
prompt = orch._build_barfly_prompt(_barfly_task())
assert "(none)" in prompt
def test_barfly_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_barfly_prompt(_barfly_task())
assert "## Prior cycles" not in prompt
def test_barfly_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_barfly_prompt(_barfly_task(), "proposed 2, approved 1")
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
@pytest.mark.asyncio
async def test_barfly_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _barfly_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_barfly_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
+204
View File
@@ -0,0 +1,204 @@
"""Coroner postmortem dispatch — Auditor-solo, EVENT-triggered, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_pest_control_dispatch.py, minus the "already authored" marker
pre-check (propose_postmortem completes the task atomically, like the
feature-spotlight dispatch — see test_roadmap_dispatch.py's sibling instead
for that shape difference)."""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import CORONER_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _coroner_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Coroner postmortem",
"description": "Autopsy an incident and propose one process change.",
"assigned_to": "auditor",
"source": CORONER_SOURCE,
"orchestration_markers": orchestration_markers
or {
"coroner_incident": {
"incident_task_id": str(uuid4()),
"kind": "bounced",
"revision_count": 3,
"title": "Chronic task",
}
},
}
@pytest.mark.asyncio
async def test_coroner_dispatch_spawns_only_auditor() -> None:
orch = _make_orch()
task = _coroner_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "_coroner_incident_context", AsyncMock(return_value="")),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_coroner_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "auditor"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_coroner_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending autopsy must NOT respawn."""
orch = _make_orch()
task = _coroner_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "_coroner_incident_context", AsyncMock(return_value="")),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_coroner_exploration(task)
await orch._dispatch_coroner_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_coroner_dispatch_skips_active_auditor() -> None:
orch = _make_orch()
task = _coroner_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_coroner_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_coroner_away_from_board_handler() -> None:
"""A board_coroner task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task`` (auditor isn't even in
_BOARD_AGENTS, so this also guards against falling into the generic
PM-assigned handler)."""
task = _coroner_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="auditor")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_coroner_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_coroner_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_coroner_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_coroner_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_coroner_prompt_names_solo_auditor_and_real_verbs() -> None:
orch = _make_orch()
task = _coroner_task()
prompt = orch._build_coroner_prompt(task)
assert "triage()" in prompt
assert "evidence(" in prompt
assert "propose_postmortem(" in prompt
assert "i_am_idle()" in prompt
incident = task["orchestration_markers"]["coroner_incident"]
assert incident["incident_task_id"] in prompt
assert "bounced" in prompt
def test_coroner_prompt_omits_evidence_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_coroner_prompt(_coroner_task())
assert "## Evidence gathered for you" not in prompt
def test_coroner_prompt_renders_evidence_when_given() -> None:
orch = _make_orch()
prompt = orch._build_coroner_prompt(
_coroner_task(), "Findings ledger:\n- [blocker] round 1: file.py:10 — AC 1"
)
assert "## Evidence gathered for you" in prompt
assert "file.py:10" in prompt
@pytest.mark.asyncio
async def test_coroner_dispatch_injects_incident_context() -> None:
orch = _make_orch()
task = _coroner_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_coroner_incident_context",
AsyncMock(return_value="- [blocker] round 1: file.py:10 — AC 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_coroner_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "file.py:10" in prompt
@pytest.mark.asyncio
async def test_coroner_incident_context_survives_db_failure() -> None:
"""A DB hiccup gathering incident context degrades to '' — never raises."""
orch = _make_orch()
task = _coroner_task()
with patch(
"roboco.services.coroner_engine.get_coroner_engine",
side_effect=RuntimeError("db down"),
):
result = await orch._coroner_incident_context(task)
assert result == ""
@pytest.mark.asyncio
async def test_coroner_incident_context_empty_without_marker() -> None:
orch = _make_orch()
task = _coroner_task(orchestration_markers={})
result = await orch._coroner_incident_context(task)
assert result == ""
+216
View File
@@ -0,0 +1,216 @@
"""Dogfood exploration dispatch — Product-Owner-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_spackle_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import DOGFOOD_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _dogfood_task(
*,
orchestration_markers: dict[str, Any] | None = None,
project_slug: str | None = None,
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Dogfood walk",
"description": "Walk the product as a user and propose a friction audit.",
"assigned_to": "product-owner",
"source": DOGFOOD_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": project_slug,
}
@pytest.mark.asyncio
async def test_dogfood_dispatch_spawns_only_product_owner() -> None:
"""A dogfood exploration task must spawn the Product Owner alone."""
orch = _make_orch()
task = _dogfood_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_dogfood_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
orch = _make_orch()
task = _dogfood_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
await orch._dispatch_dogfood_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_dogfood_dispatch_skips_once_authored() -> None:
"""Once ``propose_friction_fixes`` has stamped the friction_fixes marker,
the dispatcher must not spawn again."""
orch = _make_orch()
task = _dogfood_task(orchestration_markers={"friction_fixes": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dogfood_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _dogfood_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_dogfood_away_from_board_handler() -> None:
"""A board_dogfood task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task``."""
task = _dogfood_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_dogfood_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_dogfood_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_dogfood_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_dogfood_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_dogfood_prompt_names_solo_po_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "triage()" in prompt
assert "propose_friction_fixes(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_dogfood_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "## Prior cycles" not in prompt
def test_dogfood_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_dogfood_prompt(
_dogfood_task(),
"proposed 2, approved 1",
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
def test_dogfood_prompt_names_browser_tools() -> None:
"""The ONE board-program prompt referencing playwright tools — proves
the prompt text matches the task-scoped MCP grant (see
test_mcp_playwright_gating.py's dogfood-specific tests)."""
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task())
assert "browser_navigate" in prompt
assert "browser_snapshot" in prompt
def test_dogfood_prompt_injects_panel_url_for_self_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "self_heal_project_slug", "roboco-api")
monkeypatch.setattr(settings, "panel_base_url", "https://panel.example.com")
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task(project_slug="roboco-api"))
assert "https://panel.example.com" in prompt
def test_dogfood_prompt_omits_panel_url_for_other_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "self_heal_project_slug", "roboco-api")
monkeypatch.setattr(settings, "panel_base_url", "https://panel.example.com")
orch = _make_orch()
prompt = orch._build_dogfood_prompt(_dogfood_task(project_slug="some-other-repo"))
assert "https://panel.example.com" not in prompt
@pytest.mark.asyncio
async def test_dogfood_dispatch_injects_prior_context() -> None:
orch = _make_orch()
task = _dogfood_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_dogfood_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
@@ -0,0 +1,211 @@
"""Librarian exploration dispatch — Auditor-solo, never the two-reviewer
board-review gate (the Auditor is not in _BOARD_AGENTS), never the dev/PM
delivery dispatchers. Mirrors test_sentinel_dispatch.py (both are
complete-at-propose).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import LIBRARIAN_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _librarian_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Librarian playbook-mining cycle",
"description": "Mine journals/learnings and draft 1-3 playbooks.",
"assigned_to": "auditor",
"source": LIBRARIAN_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": None,
}
@pytest.mark.asyncio
async def test_librarian_dispatch_spawns_only_auditor() -> None:
"""A librarian mining task must spawn the Auditor alone."""
orch = _make_orch()
task = _librarian_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_librarian_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "auditor"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_librarian_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending mining task must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _librarian_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_librarian_exploration(task)
await orch._dispatch_librarian_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_librarian_dispatch_skips_active_auditor() -> None:
orch = _make_orch()
task = _librarian_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_librarian_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_librarian_source_away_from_board() -> None:
"""A board_librarian task must ride the dedicated librarian dispatcher,
never the two-reviewer ``_handle_board_assigned_task``, nor the roadmap/
pest-control/periscope/sentinel dispatchers, nor plain PM handling."""
task = _librarian_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="auditor")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._dispatch_pest_control_exploration = AsyncMock()
stub._dispatch_periscope_exploration = AsyncMock()
stub._dispatch_sentinel_exploration = AsyncMock()
stub._dispatch_spackle_exploration = AsyncMock()
stub._dispatch_scales_exploration = AsyncMock()
stub._dispatch_librarian_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_librarian_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._dispatch_feature_spotlight_exploration.assert_not_awaited()
stub._dispatch_pest_control_exploration.assert_not_awaited()
stub._dispatch_periscope_exploration.assert_not_awaited()
stub._dispatch_sentinel_exploration.assert_not_awaited()
stub._dispatch_spackle_exploration.assert_not_awaited()
stub._dispatch_scales_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_librarian_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_librarian_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_librarian_prompt_names_real_verbs() -> None:
"""The prompt must steer the Auditor to its real verbs (triage /
propose_playbook_drafts / i_am_idle), never draft_playbook."""
orch = _make_orch()
prompt = orch._build_librarian_prompt(_librarian_task())
assert "triage()" in prompt
assert "propose_playbook_drafts(" in prompt
assert "i_am_idle()" in prompt
def test_librarian_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_librarian_prompt(_librarian_task())
assert "## Prior cycles" not in prompt
def test_librarian_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_librarian_prompt(_librarian_task(), "proposed 1, approved 0")
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0" in prompt
def test_librarian_prompt_omits_mining_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_librarian_prompt(_librarian_task())
assert "## Mining context gathered for you" not in prompt
def test_librarian_prompt_renders_mining_context_when_given() -> None:
orch = _make_orch()
prompt = orch._build_librarian_prompt(
_librarian_task(), "", "Recurring learning topics:\n- 'venv rot' recurred 3x"
)
assert "## Mining context gathered for you" in prompt
assert "venv rot" in prompt
@pytest.mark.asyncio
async def test_librarian_dispatch_injects_prior_context_and_mining_into_prompt() -> (
None
):
"""The dispatcher fetches LEARN context AND mining context (both
best-effort) and threads them into the prompt builder — proving the
wiring, not just the builder in isolation."""
orch = _make_orch()
task = _librarian_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(
orch,
"_librarian_mining_context",
AsyncMock(
return_value="Recurring learning topics:\n- 'venv rot' recurred 2x"
),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_librarian_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
assert "venv rot" in prompt
@@ -1,10 +1,16 @@
"""The `playwright` MCP server is gated to fe-qa/ux-qa, plus exactly one
non-QA case: a ux-dev spawned onto a source=video authoring task (probed via
"""The `playwright` MCP server is gated to fe-qa/ux-qa, plus two non-QA
cases: a ux-dev spawned onto a source=video authoring task (probed via
``_is_video_authoring_spawn``), so the composition author can preview their
HTML in a real browser. It must never appear for be-qa (same role, different
team — no chromium in that image) or for a ux-dev outside a video task,
since the binary + wrapper entrypoint are only baked into agent-qa-fe /
agent-ux via docker/agent-qa-fe.Dockerfile / docker/agent-ux.Dockerfile.
HTML in a real browser, and a product_owner spawned onto a source=
board_dogfood task (probed via ``_is_dogfood_spawn``), so the PO can walk
the product as a user (spec: docs/internal/specs/2026-07-24-board-programs-
design.md §4 "Dogfood" — the one program needing more than read tools). It
must never appear for be-qa (same role, different team — no chromium in that
image), for a ux-dev outside a video task, or for a product_owner spawned on
ANY other board program (roadmap/pest_control/scales/etc.) — task-scoped,
not role-blanket. The binary + wrapper entrypoint are baked into agent-qa-fe
/ agent-ux / agent-pm via docker/agent-qa-fe.Dockerfile /
docker/agent-ux.Dockerfile / docker/agent-pm.Dockerfile.
"""
from __future__ import annotations
@@ -80,3 +86,57 @@ async def test_video_probe_guards_role_and_team() -> None:
assert await orch._is_video_authoring_spawn("ux-qa", "qa", "t1") is False
assert await orch._is_video_authoring_spawn("fe-dev-1", "developer", "t1") is False
assert await orch._is_video_authoring_spawn("ux-dev-1", "developer", None) is False
async def test_product_owner_on_dogfood_task_gets_playwright_mcp(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ONE board-role case: product_owner spawned onto a
source=board_dogfood task gets the playwright MCP so it can walk the
product as a user (spec §4)."""
async def _dogfood(
_self: AgentOrchestrator, agent_id: str, _agent_role: str, task_id: str | None
) -> bool:
return agent_id == "product-owner" and task_id == "t-dogfood"
monkeypatch.setattr(AgentOrchestrator, "_is_dogfood_spawn", _dogfood)
servers = await _servers_for("product-owner", task_id="t-dogfood")
assert "playwright" in servers
assert servers["playwright"]["command"] == _ENTRYPOINT
async def test_product_owner_on_other_task_still_refused_when_dogfood_probed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The probe is task-scoped: even with a real (monkeypatched) probe
wired up, a DIFFERENT task id for the same product_owner must not get
the grant — a roadmap/pest_control/scales spawn must never leak
browser tools just because Dogfood exists."""
async def _dogfood(
_self: AgentOrchestrator, agent_id: str, _agent_role: str, task_id: str | None
) -> bool:
return agent_id == "product-owner" and task_id == "t-dogfood"
monkeypatch.setattr(AgentOrchestrator, "_is_dogfood_spawn", _dogfood)
servers = await _servers_for("product-owner", task_id="t-roadmap")
assert "playwright" not in servers
async def test_head_marketing_does_not_get_playwright_mcp() -> None:
"""Another board role, never gated in for any program."""
servers = await _servers_for("head-marketing", task_id="t-periscope")
assert "playwright" not in servers
async def test_dogfood_probe_guards_role_and_task_id() -> None:
"""Early-outs need no DB: a non-product_owner role and a missing task id
both refuse before any lookup — mirrors
``test_video_probe_guards_role_and_team``."""
orch = AgentOrchestrator.__new__(AgentOrchestrator)
assert await orch._is_dogfood_spawn("main-pm", "main_pm", "t1") is False
assert await orch._is_dogfood_spawn("head-marketing", "head_marketing", "t1") is (
False
)
assert await orch._is_dogfood_spawn("product-owner", "product_owner", None) is False
@@ -0,0 +1,216 @@
"""Megaphone exploration dispatch — Head-of-Marketing-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_periscope_dispatch.py (both are complete-at-propose)."""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator, _is_non_dev_dispatch_source
from roboco.services.task import MEGAPHONE_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _megaphone_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Megaphone editorial cycle",
"description": "Pick ONE angle and file ONE post.",
"assigned_to": "head-marketing",
"source": MEGAPHONE_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": None,
}
@pytest.mark.asyncio
async def test_megaphone_dispatch_spawns_only_head_marketing() -> None:
"""A megaphone exploration task must spawn the Head of Marketing alone —
the Product Owner is not part of this cycle."""
orch = _make_orch()
task = _megaphone_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_megaphone_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_megaphone_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _megaphone_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_megaphone_exploration(task)
await orch._dispatch_megaphone_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_megaphone_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _megaphone_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_megaphone_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_megaphone_source_away_from_board() -> None:
"""A board_megaphone task must ride the dedicated megaphone dispatcher,
never the two-reviewer ``_handle_board_assigned_task``, nor the roadmap
or periscope dispatchers, nor plain PM handling."""
task = _megaphone_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._dispatch_pest_control_exploration = AsyncMock()
stub._dispatch_periscope_exploration = AsyncMock()
stub._dispatch_coroner_exploration = AsyncMock()
stub._dispatch_sentinel_exploration = AsyncMock()
stub._dispatch_spackle_exploration = AsyncMock()
stub._dispatch_megaphone_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_megaphone_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._dispatch_feature_spotlight_exploration.assert_not_awaited()
stub._dispatch_periscope_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_megaphone_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_megaphone_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_megaphone_prompt_names_real_verbs() -> None:
"""The prompt must steer HoM to its real verbs (triage /
propose_editorial_post / i_am_idle)."""
orch = _make_orch()
prompt = orch._build_megaphone_prompt(_megaphone_task())
assert "triage()" in prompt
assert "propose_editorial_post(" in prompt
assert "i_am_idle()" in prompt
def test_megaphone_prompt_names_angle_vocabulary() -> None:
orch = _make_orch()
prompt = orch._build_megaphone_prompt(_megaphone_task())
assert "dev_log" in prompt
assert "behind_scenes" in prompt
assert "changelog_highlight" in prompt
def test_megaphone_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_megaphone_prompt(_megaphone_task())
assert "## Prior cycles" not in prompt
def test_megaphone_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_megaphone_prompt(_megaphone_task(), "proposed 1, approved 0")
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0" in prompt
def test_megaphone_prompt_omits_digest_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_megaphone_prompt(_megaphone_task())
assert "## Shipped-this-week digest" not in prompt
def test_megaphone_prompt_renders_digest_when_given() -> None:
orch = _make_orch()
prompt = orch._build_megaphone_prompt(
_megaphone_task(), "", "Completed this week:\n- Shipped X (RoboCo, backend)"
)
assert "## Shipped-this-week digest" in prompt
assert "Shipped X (RoboCo, backend)" in prompt
@pytest.mark.asyncio
async def test_megaphone_dispatch_injects_prior_and_digest_context_into_prompt() -> (
None
):
"""The dispatcher fetches LEARN + digest context (both best-effort) and
threads them into the prompt builder — proving the wiring, not just the
builder in isolation."""
orch = _make_orch()
task = _megaphone_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(
orch,
"_megaphone_digest_context",
AsyncMock(return_value="Completed this week:\n- Shipped Y"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_megaphone_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
assert "Shipped Y" in prompt
def test_megaphone_is_non_dev_dispatch_source() -> None:
assert _is_non_dev_dispatch_source({"source": MEGAPHONE_SOURCE}) is True
+182
View File
@@ -0,0 +1,182 @@
"""Mirror exploration dispatch — Head-of-Marketing-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_spackle_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import MIRROR_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _mirror_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Mirror exploration cycle",
"description": "Audit messaging surfaces and propose a messaging-fixes audit.",
"assigned_to": "head-marketing",
"source": MIRROR_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_mirror_dispatch_spawns_only_head_of_marketing() -> None:
"""A mirror exploration task must spawn the Head of Marketing alone."""
orch = _make_orch()
task = _mirror_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_mirror_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_mirror_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
orch = _make_orch()
task = _mirror_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_mirror_exploration(task)
await orch._dispatch_mirror_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_mirror_dispatch_skips_once_authored() -> None:
"""Once ``propose_messaging_fixes`` has stamped the messaging_fixes
marker, the dispatcher must not spawn again."""
orch = _make_orch()
task = _mirror_task(orchestration_markers={"messaging_fixes": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_mirror_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_mirror_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _mirror_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_mirror_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_mirror_away_from_board_handler() -> None:
"""A board_mirror task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task``."""
task = _mirror_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_mirror_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_mirror_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_mirror_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_mirror_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_mirror_prompt_names_solo_hom_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_mirror_prompt(_mirror_task())
assert "triage()" in prompt
assert "propose_messaging_fixes(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_mirror_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_mirror_prompt(_mirror_task())
assert "## Prior cycles" not in prompt
def test_mirror_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_mirror_prompt(
_mirror_task(),
"proposed 2, approved 1",
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
@pytest.mark.asyncio
async def test_mirror_dispatch_injects_prior_context() -> None:
orch = _make_orch()
task = _mirror_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_mirror_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
@@ -0,0 +1,232 @@
"""Periscope exploration dispatch — Head-of-Marketing-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_feature_spotlight_dispatch.py (both are complete-at-propose).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import PERISCOPE_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _periscope_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Periscope market-research cycle",
"description": "Research the market and file ONE brief.",
"assigned_to": "head-marketing",
"source": PERISCOPE_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": None,
}
@pytest.mark.asyncio
async def test_periscope_dispatch_spawns_only_head_marketing() -> None:
"""A periscope exploration task must spawn the Head of Marketing alone —
the Product Owner is not part of this cycle."""
orch = _make_orch()
task = _periscope_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_periscope_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_periscope_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _periscope_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_periscope_exploration(task)
await orch._dispatch_periscope_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_periscope_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _periscope_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_periscope_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_periscope_source_away_from_board() -> None:
"""A board_periscope task must ride the dedicated periscope dispatcher,
never the two-reviewer ``_handle_board_assigned_task``, nor the roadmap
or feature-spotlight dispatchers, nor plain PM handling."""
task = _periscope_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._dispatch_pest_control_exploration = AsyncMock()
stub._dispatch_periscope_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_periscope_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._dispatch_feature_spotlight_exploration.assert_not_awaited()
stub._dispatch_pest_control_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_periscope_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_periscope_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_periscope_prompt_names_real_verbs() -> None:
"""The prompt must steer HoM to its real verbs (triage /
propose_market_brief / i_am_idle)."""
orch = _make_orch()
prompt = orch._build_periscope_prompt(_periscope_task())
assert "triage()" in prompt
assert "propose_market_brief(" in prompt
assert "i_am_idle()" in prompt
def test_periscope_prompt_names_uncited_finding_rejection() -> None:
orch = _make_orch()
prompt = orch._build_periscope_prompt(_periscope_task())
assert "source_url" in prompt
assert "rejects" in prompt.lower()
def test_periscope_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_periscope_prompt(_periscope_task())
assert "## Prior cycles" not in prompt
def test_periscope_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_periscope_prompt(_periscope_task(), "proposed 1, approved 0")
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0" in prompt
@pytest.mark.asyncio
async def test_periscope_dispatch_injects_prior_context_into_prompt() -> None:
"""The dispatcher fetches LEARN context (best-effort) and threads it into
the prompt builder — proving the wiring, not just the builder in
isolation."""
orch = _make_orch()
task = _periscope_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_periscope_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
def test_roadmap_prompt_injects_latest_market_brief_when_given() -> None:
"""The spec's cross-role feed: Periscope's latest brief reaches the
roadmap exploration prompt, clearly labeled as HoM market input."""
orch = _make_orch()
task = {"id": str(uuid4())}
prompt = orch._build_roadmap_prompt(
task, "", "Headline: A rival shipped agentic PR review\n- claim (source: url)"
)
assert "Head of Marketing" in prompt
assert "market brief" in prompt.lower()
assert "A rival shipped agentic PR review" in prompt
def test_roadmap_prompt_omits_market_brief_section_when_empty() -> None:
orch = _make_orch()
task = {"id": str(uuid4())}
prompt = orch._build_roadmap_prompt(task)
assert "Periscope" not in prompt
@pytest.mark.asyncio
async def test_roadmap_dispatch_injects_latest_market_brief_into_prompt() -> None:
"""The roadmap dispatcher fetches Periscope's latest brief (best-effort)
and threads it into the prompt builder — proving the wiring end to end."""
orch = _make_orch()
task = {
"id": str(uuid4()),
"orchestration_markers": None,
"assigned_to": "product-owner",
}
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "_board_program_prior_context", AsyncMock(return_value="")),
patch.object(
orch,
"_periscope_brief_context",
AsyncMock(return_value="Headline: competitor signal"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "Headline: competitor signal" in prompt
@@ -0,0 +1,204 @@
"""Pest Control exploration dispatch — Product-Owner-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_roadmap_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import PEST_CONTROL_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _pest_control_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Pest Control exploration cycle",
"description": "Hunt latent defects and propose a bug hunt.",
"assigned_to": "product-owner",
"source": PEST_CONTROL_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_pest_control_dispatch_spawns_only_product_owner() -> None:
"""A pest-control exploration task must spawn the Product Owner alone."""
orch = _make_orch()
task = _pest_control_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_pest_control_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_pest_control_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
orch = _make_orch()
task = _pest_control_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_pest_control_exploration(task)
await orch._dispatch_pest_control_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_pest_control_dispatch_skips_once_authored() -> None:
"""Once ``propose_bug_hunt`` has stamped the pest_hunt marker, the
dispatcher must not spawn again."""
orch = _make_orch()
task = _pest_control_task(orchestration_markers={"pest_hunt": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_pest_control_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_pest_control_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _pest_control_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_pest_control_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_pest_control_away_from_board_handler() -> None:
"""A board_pest_control task must ride the dedicated dispatcher, never
the two-reviewer ``_handle_board_assigned_task``."""
task = _pest_control_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_pest_control_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_pest_control_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_pest_control_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_pest_control_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_pest_control_prompt_names_solo_po_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_pest_control_prompt(_pest_control_task())
assert "triage()" in prompt
assert "propose_bug_hunt(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_pest_control_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_pest_control_prompt(_pest_control_task())
assert "## Prior cycles" not in prompt
assert "## Evidence gathered for you" not in prompt
def test_pest_control_prompt_renders_prior_cycles_and_evidence_when_given() -> None:
orch = _make_orch()
prompt = orch._build_pest_control_prompt(
_pest_control_task(),
"proposed 2, approved 1",
"Rework hotspots (revision_count >= 2):\n- Chronic task — bounced 3x",
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
assert "## Evidence gathered for you" in prompt
assert "Chronic task — bounced 3x" in prompt
@pytest.mark.asyncio
async def test_pest_control_dispatch_injects_prior_context_and_evidence() -> None:
orch = _make_orch()
task = _pest_control_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(
orch,
"_pest_control_evidence_context",
AsyncMock(return_value="- Chronic task — bounced 3x"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_pest_control_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
assert "Chronic task — bounced 3x" in prompt
@pytest.mark.asyncio
async def test_pest_control_evidence_context_survives_db_failure() -> None:
"""A DB hiccup gathering evidence degrades to '' — never raises."""
orch = _make_orch()
with patch(
"roboco.services.pest_control_engine.get_pest_control_engine",
side_effect=RuntimeError("db down"),
):
result = await orch._pest_control_evidence_context()
assert result == ""
+206
View File
@@ -0,0 +1,206 @@
"""Scales exploration dispatch — Product-Owner-solo, never the two-reviewer
board-review gate, never the dev/PM delivery dispatchers. Mirrors
test_pest_control_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import SCALES_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _scales_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Scales portfolio-rebalance cycle",
"description": "Review the live backlog and propose a rebalance.",
"assigned_to": "product-owner",
"source": SCALES_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_scales_dispatch_spawns_only_product_owner() -> None:
"""A scales exploration task must spawn the Product Owner alone."""
orch = _make_orch()
task = _scales_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_scales_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_scales_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
orch = _make_orch()
task = _scales_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_scales_exploration(task)
await orch._dispatch_scales_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_scales_dispatch_skips_once_authored() -> None:
"""Once ``propose_rebalance`` has stamped the rebalance_plan marker, the
dispatcher must not spawn again."""
orch = _make_orch()
task = _scales_task(orchestration_markers={"rebalance_plan": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_scales_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_scales_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _scales_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_scales_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_scales_away_from_board_handler() -> None:
"""A board_scales task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task``."""
task = _scales_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_scales_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_scales_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_scales_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_scales_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_scales_prompt_names_solo_po_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_scales_prompt(_scales_task())
assert "triage()" in prompt
assert "propose_rebalance(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_scales_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_scales_prompt(_scales_task())
assert "## Prior cycles" not in prompt
assert "## Evidence gathered for you" not in prompt
def test_scales_prompt_renders_prior_cycles_and_evidence_when_given() -> None:
orch = _make_orch()
evidence = (
"Stale backlog (BACKLOG/PENDING, >= 30d old...):\n"
"- abc12345 'Old task' — P2, 45d in the backlog"
)
prompt = orch._build_scales_prompt(
_scales_task(), "proposed 2, approved 1", evidence
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
assert "## Evidence gathered for you" in prompt
assert "abc12345 'Old task'" in prompt
@pytest.mark.asyncio
async def test_scales_dispatch_injects_prior_context_and_evidence() -> None:
orch = _make_orch()
task = _scales_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(
orch,
"_scales_evidence_context",
AsyncMock(return_value="- abc12345 'Old task' — P2, 45d in the backlog"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_scales_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
assert "abc12345 'Old task'" in prompt
@pytest.mark.asyncio
async def test_scales_evidence_context_survives_db_failure() -> None:
"""A DB hiccup gathering evidence degrades to '' — never raises."""
orch = _make_orch()
with patch(
"roboco.services.scales_engine.get_scales_engine",
side_effect=RuntimeError("db down"),
):
result = await orch._scales_evidence_context()
assert result == ""
@@ -0,0 +1,210 @@
"""Sentinel exploration dispatch — Auditor-solo, never the two-reviewer
board-review gate (the Auditor is not in _BOARD_AGENTS), never the dev/PM
delivery dispatchers. Mirrors test_periscope_dispatch.py (both are
complete-at-propose).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import SENTINEL_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _sentinel_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Sentinel drift-watch cycle",
"description": "Assess org-wide quality drift and file ONE report.",
"assigned_to": "auditor",
"source": SENTINEL_SOURCE,
"orchestration_markers": orchestration_markers,
"project_slug": None,
}
@pytest.mark.asyncio
async def test_sentinel_dispatch_spawns_only_auditor() -> None:
"""A sentinel exploration task must spawn the Auditor alone."""
orch = _make_orch()
task = _sentinel_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_sentinel_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "auditor"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_sentinel_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _sentinel_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_sentinel_exploration(task)
await orch._dispatch_sentinel_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_sentinel_dispatch_skips_active_auditor() -> None:
orch = _make_orch()
task = _sentinel_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_sentinel_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_sentinel_source_away_from_board() -> None:
"""A board_sentinel task must ride the dedicated sentinel dispatcher,
never the two-reviewer ``_handle_board_assigned_task``, nor the roadmap/
pest-control/periscope dispatchers, nor plain PM handling."""
task = _sentinel_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="auditor")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._dispatch_pest_control_exploration = AsyncMock()
stub._dispatch_periscope_exploration = AsyncMock()
stub._dispatch_sentinel_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_sentinel_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._dispatch_feature_spotlight_exploration.assert_not_awaited()
stub._dispatch_pest_control_exploration.assert_not_awaited()
stub._dispatch_periscope_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_sentinel_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_sentinel_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_sentinel_prompt_names_real_verbs() -> None:
"""The prompt must steer the Auditor to its real verbs (triage /
propose_quality_report / i_am_idle)."""
orch = _make_orch()
prompt = orch._build_sentinel_prompt(_sentinel_task())
assert "triage()" in prompt
assert "propose_quality_report(" in prompt
assert "i_am_idle()" in prompt
def test_sentinel_prompt_names_area_vocabulary() -> None:
orch = _make_orch()
prompt = orch._build_sentinel_prompt(_sentinel_task())
for area in ("waivers", "findings", "conventions", "budget", "docs", "other"):
assert area in prompt
def test_sentinel_prompt_omits_prior_cycles_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_sentinel_prompt(_sentinel_task())
assert "## Prior cycles" not in prompt
def test_sentinel_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_sentinel_prompt(_sentinel_task(), "proposed 1, approved 0")
assert "## Prior cycles" in prompt
assert "proposed 1, approved 0" in prompt
def test_sentinel_prompt_omits_evidence_section_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_sentinel_prompt(_sentinel_task())
assert "## Evidence gathered for you" not in prompt
def test_sentinel_prompt_renders_evidence_when_given() -> None:
orch = _make_orch()
prompt = orch._build_sentinel_prompt(
_sentinel_task(), "", "Open findings by severity:\n- blocker: 1 open"
)
assert "## Evidence gathered for you" in prompt
assert "blocker: 1 open" in prompt
@pytest.mark.asyncio
async def test_sentinel_dispatch_injects_prior_context_and_evidence_into_prompt() -> (
None
):
"""The dispatcher fetches LEARN context AND evidence context (both
best-effort) and threads them into the prompt builder proving the
wiring, not just the builder in isolation."""
orch = _make_orch()
task = _sentinel_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 1, approved 1"),
),
patch.object(
orch,
"_sentinel_evidence_context",
AsyncMock(return_value="Waived findings this week vs prior:\n- 2 waived"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_sentinel_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 1, approved 1" in prompt
assert "2 waived" in prompt
+182
View File
@@ -0,0 +1,182 @@
"""Spackle exploration dispatch — Product-Owner-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
Mirrors test_pest_control_dispatch.py.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import SPACKLE_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _spackle_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Spackle exploration cycle",
"description": "Audit half-shipped surface area and propose a gap-fill audit.",
"assigned_to": "product-owner",
"source": SPACKLE_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_spackle_dispatch_spawns_only_product_owner() -> None:
"""A spackle exploration task must spawn the Product Owner alone."""
orch = _make_orch()
task = _spackle_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_spackle_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_spackle_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
orch = _make_orch()
task = _spackle_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_spackle_exploration(task)
await orch._dispatch_spackle_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_spackle_dispatch_skips_once_authored() -> None:
"""Once ``propose_gap_fill`` has stamped the gap_fill marker, the
dispatcher must not spawn again."""
orch = _make_orch()
task = _spackle_task(orchestration_markers={"gap_fill": {"items": []}})
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_spackle_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_spackle_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _spackle_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_spackle_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_spackle_away_from_board_handler() -> None:
"""A board_spackle task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task``."""
task = _spackle_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_spackle_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_spackle_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_spackle_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_spackle_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_spackle_prompt_names_solo_po_and_real_verbs() -> None:
orch = _make_orch()
prompt = orch._build_spackle_prompt(_spackle_task())
assert "triage()" in prompt
assert "propose_gap_fill(" in prompt
assert "i_am_idle()" in prompt
assert "do not" in prompt.lower()
def test_spackle_prompt_omits_optional_sections_when_empty() -> None:
orch = _make_orch()
prompt = orch._build_spackle_prompt(_spackle_task())
assert "## Prior cycles" not in prompt
def test_spackle_prompt_renders_prior_cycles_when_given() -> None:
orch = _make_orch()
prompt = orch._build_spackle_prompt(
_spackle_task(),
"proposed 2, approved 1",
)
assert "## Prior cycles" in prompt
assert "proposed 2, approved 1" in prompt
@pytest.mark.asyncio
async def test_spackle_dispatch_injects_prior_context() -> None:
orch = _make_orch()
task = _spackle_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_board_program_prior_context",
AsyncMock(return_value="proposed 2, approved 1"),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_spackle_exploration(task)
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
assert "proposed 2, approved 1" in prompt
@@ -0,0 +1,160 @@
"""War Room campaign-planning dispatch — Head-of-Marketing-solo,
EVENT-triggered, never the two-reviewer board-review gate, never the dev/PM
delivery dispatchers. Mirrors test_coroner_dispatch.py: complete-at-propose
(no "already authored" marker pre-check propose_campaign completes the
task atomically), and no extra async context-gathering helper (the release
brief, or {} for a blank on-demand cycle, is already ON the task's own
``war_room_brief`` marker at origination time no separate DB read needed).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import WAR_ROOM_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _war_room_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "War Room campaign planning",
"description": "Design one campaign and propose it.",
"assigned_to": "head-marketing",
"source": WAR_ROOM_SOURCE,
"orchestration_markers": orchestration_markers
if orchestration_markers is not None
else {
"war_room_brief": {
"version": "0.30.0",
"highlights": ["MegaTask v2", "Findings ledger"],
}
},
}
@pytest.mark.asyncio
async def test_war_room_dispatch_spawns_only_hom() -> None:
orch = _make_orch()
task = _war_room_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_war_room_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_war_room_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending campaign-planning task must NOT respawn."""
orch = _make_orch()
task = _war_room_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_war_room_exploration(task)
await orch._dispatch_war_room_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_war_room_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _war_room_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_war_room_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_war_room_away_from_board_handler() -> None:
"""A board_war_room task must ride the dedicated dispatcher, never the
two-reviewer ``_handle_board_assigned_task`` or the generic PM-assigned
handler."""
task = _war_room_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_war_room_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_war_room_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_war_room_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_war_room_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_war_room_prompt_names_solo_hom_and_real_verbs() -> None:
orch = _make_orch()
task = _war_room_task()
prompt = orch._build_war_room_prompt(task)
assert "triage()" in prompt
assert "propose_campaign(" in prompt
assert "i_am_idle()" in prompt
assert "v0.30.0" in prompt
assert "MegaTask v2" in prompt
def test_war_room_prompt_renders_blank_brief_message_when_absent() -> None:
orch = _make_orch()
task = _war_room_task(orchestration_markers={"war_room_brief": {}})
prompt = orch._build_war_room_prompt(task)
assert "No release triggered this cycle" in prompt
def test_war_room_prompt_renders_blank_brief_message_with_no_marker() -> None:
"""A malformed/absent marker degrades to the same blank-brief branch,
never a KeyError."""
orch = _make_orch()
task = _war_room_task(orchestration_markers={})
prompt = orch._build_war_room_prompt(task)
assert "No release triggered this cycle" in prompt