This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_ws3_dispatch_invoker.py
Adam Moussa 7c2303a76c fix(agent-team): derive declared_scope from the diff when no plan scope is set
End-to-end validation surfaced that auto-dispatch parked every task at
"empty declared_scope": dispatch_node required plan["scope"], but the planner
emits only summary+phases (never a scope) and config.allowed_scope defaults to
None, so no node ever populated it. (The earlier dispatch test was
operator-initiated with an explicit scope; the auto planner->build->dispatch
path was never exercised until box-side App dispatch went live.)

Fix: when no planner-/operator-declared scope is present, dispatch_node derives
declared_scope from the candidate diff's own touched paths (ci_gate.diff_touched_paths).
This supplies the missing scope without relaxing any CI trust control — the
apply/verify workflow still INDEPENDENTLY re-checks the materialized diff against
the denylist + '..'-escape + this scope + the diff-hash binding, and the
agent-apply environment's required reviewer remains the human gate. A non-empty
diff that parses to zero touched paths still parks (fail closed).

Tests: the three old park-on-missing-scope cases now assert scope-from-diff
dispatch; added a park case for a diff with no parseable paths. 1527 pass.
2026-06-24 17:56:19 -04:00

548 lines
20 KiB
Python

"""Unit tests for WS3: agent_team.nodes.dispatch_invoker + graph/coordinator wiring.
Tests cover:
* :func:`make_dispatch_node` — factory contract, fail-closed on missing fields
(parks on missing thread_id/diff/scope), injection of owner/repo at factory
time (never read from state), scope list flattening, happy-path returns empty
dict (partial state update) and calls dispatcher.
* :func:`agent_team.graph.build_graph` with ``dispatch_node`` wired — verifies
the DISPATCH_NODE constant is exported and the graph compiles with/without it.
* :func:`agent_team.coordinator.default_dispatch_node_factory` — env-var binding
and fail-closed when required vars are unset.
* :class:`agent_team.coordinator.Coordinator` ``dispatch_node_wiring`` seam —
accepted at __init__, NOT called in setup() when build_verify_wiring is None
(DISPATCH_NODE requires VERIFY_NODE/APPROVED_ROUTE to exist).
All network + subprocess calls are replaced with injected fakes; no model, no
git, no GitHub, no subprocess.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_team.nodes.dispatch_invoker import DispatchNodeFactory, make_dispatch_node
from agent_team.task_model import Phase, TaskStatus
_FAKE_RUN_ID = "27990718108"
@pytest.fixture(autouse=True)
def _stub_run_locator(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub the real ``gh run list`` locator so no test shells out / sleeps.
``dispatch_apply_verify`` resolves the dispatched run via ``_default_run_locator``
when no ``locator`` is injected; the real one polls ``gh`` with retry sleeps.
Replace it module-wide with a fast fake returning a fixed run id so every
``make_dispatch_node`` call (incl. factory paths) stays hermetic.
"""
from agent_team import dispatcher as _dispatcher
monkeypatch.setattr(
_dispatcher,
"_default_run_locator",
lambda: lambda **_kw: _FAKE_RUN_ID,
)
# --------------------------------------------------------------------------- #
# Fake BranchPusher / WorkflowDispatcher (the two injectable seams in dispatcher)
# --------------------------------------------------------------------------- #
def _make_fake_pusher() -> tuple[list[dict[str, Any]], Any]:
calls: list[dict[str, Any]] = []
def _pusher(
*, owner: str, repo: str, base: str, head_branch: str, diff_text: str
) -> None:
calls.append(dict(owner=owner, repo=repo, base=base, head_branch=head_branch))
return calls, _pusher
def _make_fake_workflow_dispatcher() -> tuple[list[dict[str, Any]], Any]:
calls: list[dict[str, Any]] = []
def _dispatcher(*, owner: str, repo: str, inputs: dict[str, str], ref: str) -> None:
calls.append(dict(owner=owner, repo=repo, ref=ref, inputs=inputs))
return calls, _dispatcher
def _fake_seams() -> tuple[Any, Any]:
"""Return (fake_pusher, fake_dispatcher) that silence all subprocess calls."""
_, pusher = _make_fake_pusher()
_, dispatcher = _make_fake_workflow_dispatcher()
return pusher, dispatcher
_VALID_STATE: dict[str, Any] = {
"thread_id": "task-abc",
"candidate_diff": (
"diff --git a/f.py b/f.py\n--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n-old\n+new"
),
"plan": {"scope": ["agent_team/"]},
"status": "active",
"current_phase": "verify",
}
# --------------------------------------------------------------------------- #
# make_dispatch_node — happy path
# --------------------------------------------------------------------------- #
def test_dispatch_node_happy_path_persists_run_id() -> None:
"""On success the node persists the located run identity into state.
The dispatched run's id (plus the dispatched-at watermark and correlation
tag) flows into PipelineState so the verifier's read-only fetcher polls THIS
task's run and the pure-code gate binds its verdict to it.
"""
pusher, dispatcher = _fake_seams()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
result = node(_VALID_STATE)
assert isinstance(result, dict)
assert result["run_id"] == _FAKE_RUN_ID
assert result["dispatched_at"] # stamped
assert result["ci_correlation_tag"] == "task-abc"
def test_dispatch_node_injects_owner_repo_at_factory_time() -> None:
"""owner/repo come from factory args — model output in state cannot redirect them."""
pusher_calls, pusher = _make_fake_pusher()
_, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="trusted-org", repo="trusted-repo", pusher=pusher, dispatcher=dispatcher
)
node(_VALID_STATE)
assert pusher_calls, "pusher should have been called"
assert pusher_calls[0]["owner"] == "trusted-org"
assert pusher_calls[0]["repo"] == "trusted-repo"
def test_dispatch_node_passes_task_id_to_head_branch() -> None:
pusher_calls, pusher = _make_fake_pusher()
_, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", base="main", pusher=pusher, dispatcher=dispatcher
)
node(_VALID_STATE)
assert pusher_calls, "pusher should have been called"
# build_dispatch_inputs derives head_branch from task_id.
assert "task-abc" in pusher_calls[0]["head_branch"]
assert pusher_calls[0]["base"] == "main"
def test_dispatch_node_passes_scope_to_workflow_dispatcher() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
node(_VALID_STATE)
assert disp_calls, "workflow dispatcher should have been called"
wf_inputs = disp_calls[0]["inputs"]
assert "agent_team/" in str(wf_inputs)
def test_dispatch_node_flattens_scope_list_to_string() -> None:
"""Multi-entry scope list is newline-joined into declared_scope."""
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, plan={"scope": ["agent_team/", "tests/"]})
node(state)
wf_inputs = disp_calls[0]["inputs"]
scope_str = str(wf_inputs)
assert "agent_team/" in scope_str
def test_dispatch_node_unresolved_run_id_persists_watermark_and_verify_fails_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ``if not result.run_id`` branch: dispatch fired but the run could not
be correlated. The node must NOT park (the dispatch succeeded) — it persists
the dispatched-at watermark + correlation tag with ``run_id=None`` so the
downstream verifier gate fails closed (BLOCK/park), never fabricating a pass.
"""
from agent_team import dispatcher as _dispatcher
# Override the autouse locator stub: this run cannot be located -> None.
monkeypatch.setattr(_dispatcher, "_default_run_locator", lambda: lambda **_kw: None)
pusher, dispatcher = _fake_seams()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
result = node(_VALID_STATE)
# Not parked: the dispatch itself succeeded; only correlation failed.
assert result.get("status") != TaskStatus.PARKED.value
assert result["run_id"] is None
# The watermark + correlation tag are still persisted for the CI-watch timeout
# and for audit, even though the run id is unresolved.
assert result["dispatched_at"]
assert result["ci_correlation_tag"] == "task-abc"
# Feed the dispatch output into the verifier: a None run_id has nothing for
# the gate to bind to, so it BLOCKs and parks — fail closed, never a pass.
from agent_team.nodes.build_verify_subgraph import make_verify_node
from agent_team.nodes.verifier import VerifierConfig
verify_state: dict[str, Any] = {
"thread_id": "task-abc",
"candidate_diff": _VALID_STATE["candidate_diff"],
"diff_hash": "deadbeef",
"ci_results": None,
"run_id": result["run_id"], # None, carried from dispatch
}
def _pass_fetcher(_s: Any) -> Any:
# Even a 'success' CI result cannot rescue a missing run_id: the gate has
# no run identity to bind the verdict to.
return {"run_id": "whatever", "conclusion": "success", "diff_hash": "deadbeef"}
verify_node = make_verify_node(
VerifierConfig(expected_run_id=None, allowed_scope=["agent_team/"]),
ci_result_fetcher=_pass_fetcher,
)
out = verify_node(verify_state)
assert out["status"] == TaskStatus.PARKED.value
assert out["ci_results"]["gate_decision"] == "block"
# --------------------------------------------------------------------------- #
# make_dispatch_node — fail closed (never dispatches incomplete input)
# --------------------------------------------------------------------------- #
def test_dispatch_node_parks_on_missing_thread_id() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, thread_id="")
result = node(state)
assert result.get("status") == TaskStatus.PARKED.value
assert result.get("current_phase") == Phase.PARKED.value
assert not disp_calls # dispatcher never called
def test_dispatch_node_parks_on_missing_diff() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, candidate_diff="")
result = node(state)
assert result.get("status") == TaskStatus.PARKED.value
assert not disp_calls
def test_dispatch_node_parks_on_whitespace_only_diff() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, candidate_diff=" \n ")
result = node(state)
assert result.get("status") == TaskStatus.PARKED.value
assert not disp_calls
def test_dispatch_node_derives_scope_from_diff_when_plan_scope_empty() -> None:
# The planner never emits a scope, so an empty plan scope must NOT park: the
# node derives declared_scope from the candidate diff's touched paths
# (_VALID_STATE's diff touches f.py) and dispatches.
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, plan={"scope": []})
result = node(state)
assert result.get("status") != TaskStatus.PARKED.value
assert len(disp_calls) == 1
assert disp_calls[0]["inputs"]["declared_scope"] == "f.py"
def test_dispatch_node_derives_scope_from_diff_when_plan_none() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, plan=None)
result = node(state)
assert result.get("status") != TaskStatus.PARKED.value
assert disp_calls[0]["inputs"]["declared_scope"] == "f.py"
def test_dispatch_node_derives_scope_from_diff_when_plan_non_dict() -> None:
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(_VALID_STATE, plan="not-a-dict")
result = node(state)
assert result.get("status") != TaskStatus.PARKED.value
assert disp_calls[0]["inputs"]["declared_scope"] == "f.py"
def test_dispatch_node_parks_when_no_scope_and_diff_touches_no_paths() -> None:
# The genuine park case: no plan scope AND a (non-empty) diff from which no
# touched path can be parsed -> nothing honest to declare -> fail closed.
_, pusher = _make_fake_pusher()
disp_calls, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
state = dict(
_VALID_STATE, plan={"scope": []}, candidate_diff="not a real diff, no headers\n"
)
result = node(state)
assert result.get("status") == TaskStatus.PARKED.value
assert not disp_calls
def test_dispatch_node_parks_on_dispatcher_error() -> None:
"""A DispatcherError from the underlying dispatcher parks the task (fail safe)."""
from agent_team.dispatcher import DispatcherError
def _bad_pusher(**_kwargs: Any) -> None:
raise DispatcherError("invalid owner/repo 'x'/'y'")
_, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=_bad_pusher, dispatcher=dispatcher
)
result = node(_VALID_STATE)
assert result.get("status") == TaskStatus.PARKED.value
def test_dispatch_node_parks_on_unexpected_exception() -> None:
"""Any unexpected error parks the task rather than crashing the graph."""
def _exploding_pusher(**_kwargs: Any) -> None:
raise RuntimeError("network timeout")
_, dispatcher = _make_fake_workflow_dispatcher()
node = make_dispatch_node(
owner="org", repo="repo", pusher=_exploding_pusher, dispatcher=dispatcher
)
result = node(_VALID_STATE)
assert result.get("status") == TaskStatus.PARKED.value
# --------------------------------------------------------------------------- #
# DispatchNodeFactory type alias export
# --------------------------------------------------------------------------- #
def test_dispatch_node_factory_type_is_exported() -> None:
from agent_team.nodes import dispatch_invoker
assert hasattr(dispatch_invoker, "DispatchNodeFactory")
def test_dispatch_node_factory_satisfies_zero_arg_contract() -> None:
"""DispatchNodeFactory is a zero-arg callable that returns a node callable."""
pusher, dispatcher = _fake_seams()
factory: DispatchNodeFactory = lambda: make_dispatch_node( # noqa: E731
owner="o", repo="r", pusher=pusher, dispatcher=dispatcher
)
node = factory()
assert callable(node)
result = node(_VALID_STATE)
assert isinstance(result, dict)
# --------------------------------------------------------------------------- #
# graph.build_graph — DISPATCH_NODE constant and compile-time wiring
# --------------------------------------------------------------------------- #
def test_build_graph_dispatch_node_constant_is_exported() -> None:
from agent_team import graph as graph_mod
assert hasattr(graph_mod, "DISPATCH_NODE")
assert graph_mod.DISPATCH_NODE == "dispatch_node"
def test_build_graph_without_dispatch_node_compiles() -> None:
"""Default (no dispatch_node) builds fine — backward-compatible."""
from langgraph.checkpoint.memory import MemorySaver
from agent_team.graph import build_graph
graph = build_graph(MemorySaver())
assert graph is not None
def test_build_graph_with_dispatch_node_no_build_verify_raises() -> None:
"""dispatch_node without build_verify raises ValueError (APPROVED_ROUTE requires P3)."""
from langgraph.checkpoint.memory import MemorySaver
from agent_team.graph import build_graph
def _fake_dispatch(state: Any) -> Any:
return {}
with pytest.raises(ValueError, match="dispatch_node requires build_verify"):
build_graph(MemorySaver(), dispatch_node=_fake_dispatch)
# --------------------------------------------------------------------------- #
# coordinator.default_dispatch_node_factory
# --------------------------------------------------------------------------- #
def test_default_dispatch_node_factory_raises_without_env_vars(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("AGENT_TEAM_REPO_OWNER", raising=False)
monkeypatch.delenv("AGENT_TEAM_REPO_NAME", raising=False)
from agent_team.coordinator import default_dispatch_node_factory
with pytest.raises(RuntimeError, match="AGENT_TEAM_REPO_OWNER"):
default_dispatch_node_factory()
def test_default_dispatch_node_factory_raises_when_only_owner_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org")
monkeypatch.delenv("AGENT_TEAM_REPO_NAME", raising=False)
from agent_team.coordinator import default_dispatch_node_factory
with pytest.raises(RuntimeError):
default_dispatch_node_factory()
def test_default_dispatch_node_factory_returns_callable_with_env_vars(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "test-org")
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "test-repo")
monkeypatch.delenv("AGENT_TEAM_BASE_BRANCH", raising=False)
from agent_team.coordinator import default_dispatch_node_factory
node = default_dispatch_node_factory()
assert callable(node)
def test_default_dispatch_node_factory_reads_base_branch_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org")
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo")
monkeypatch.setenv("AGENT_TEAM_BASE_BRANCH", "develop")
from agent_team.coordinator import default_dispatch_node_factory
node = default_dispatch_node_factory()
assert callable(node)
def test_default_dispatch_node_factory_in_all() -> None:
from agent_team import coordinator
assert "default_dispatch_node_factory" in coordinator.__all__
# --------------------------------------------------------------------------- #
# Coordinator.dispatch_node_wiring seam
# --------------------------------------------------------------------------- #
def test_coordinator_accepts_dispatch_node_wiring_param() -> None:
"""Coordinator.__init__ accepts dispatch_node_wiring=None without error."""
from agent_team.coordinator import Coordinator
from agent_team.transport.base import Transport
transport = MagicMock(spec=Transport)
coord = Coordinator(
db_path=":memory:",
transport=transport,
dispatch_node_wiring=None,
)
assert coord is not None
def test_coordinator_dispatch_node_wiring_without_build_verify_raises(
tmp_path: Any,
) -> None:
"""setup() raises ValueError when dispatch_node_wiring is set but build_verify_wiring is None.
build_graph enforces that dispatch_node requires build_verify (APPROVED_ROUTE
lives inside the P3 subgraph). Passing dispatch_node_wiring alone is a
configuration error caught at setup time.
"""
from langgraph.checkpoint.memory import MemorySaver
from agent_team.coordinator import Coordinator
from agent_team.transport.base import Transport
pusher, dispatcher = _fake_seams()
def _factory() -> Any:
return make_dispatch_node(
owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher
)
def _stub_clarify_node() -> Any:
return MagicMock()
def _stub_checkpointer(db_path: Any) -> Any:
return MemorySaver()
transport = MagicMock(spec=Transport)
coord = Coordinator(
db_path=tmp_path / "test.db",
transport=transport,
build_clarify_node=_stub_clarify_node,
build_checkpointer=_stub_checkpointer,
dispatch_node_wiring=_factory,
# build_verify_wiring=None (default)
)
with pytest.raises(ValueError, match="dispatch_node requires build_verify"):
coord.setup()