0c-binding: per-task expected_run_id bound from state (gate rejects substituted run_id; None -> BLOCK, never vacuous pass). 0e: fail-safe serve default (failsafe_production_p3_wiring) — inert on unprovisioned env (one WARNING + one #agent-team notice), never crash-loops. 0a: reorder P3 subgraph BUILD -> DISPATCH -> VERIFY (preserves _instrument). 0d: ci_watcher engine + VERIFY interrupt()-wait (async resume-on-CI-complete). KNOWN-OPEN (adversarial review BLOCKs, to remediate next): - CI-watcher not wired into run-team serve (ci_pending_provider/ci_poller None) -> a VERIFY-suspended task never resumes/parks. - no durable ci_pending_provider enumerating threads suspended at VERIFY. Branch only; not merged, not deployed.
252 lines
9.4 KiB
Python
252 lines
9.4 KiB
Python
"""P3 fail-safe serve default (design Decision 5; UNIT 0e).
|
|
|
|
The bound P3 build→verify + dispatch wiring is the new production ``serve``
|
|
default, but its factories are called EAGERLY at graph-build and the live
|
|
dispatch factory RAISES when ``AGENT_TEAM_REPO_OWNER`` / ``AGENT_TEAM_REPO_NAME``
|
|
are unset. These tests pin the fail-safe contract:
|
|
|
|
* :func:`agent_team.coordinator._p3_env_is_configured` truth table.
|
|
* :func:`agent_team.coordinator.failsafe_production_p3_wiring` —
|
|
env-unset degrades to the INERT ``(None, None)`` pair with exactly ONE WARNING
|
|
and ONE ``#agent-team`` inert notice via the lifecycle ``notify`` sink (never
|
|
the ALARM path), and NEVER raises; env-set returns the live wiring pair.
|
|
* a Coordinator built with the env-unset (inert) result sets up cleanly and an
|
|
approved task settles at the P2 BUILD terminus (no P3 nodes, no dispatch, no
|
|
exception) — i.e. a task that would reach P3 parks short of build/dispatch.
|
|
* ``run-team.py serve`` binds the fail-safe pair; ``start`` / ``intake`` do not.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from agent_team import graph as graph_mod
|
|
from agent_team.coordinator import (
|
|
Coordinator,
|
|
_p3_env_is_configured,
|
|
default_dispatch_node_factory,
|
|
failsafe_production_p3_wiring,
|
|
)
|
|
|
|
try: # InMemorySaver is the modern name; fall back on older langgraph.
|
|
from langgraph.checkpoint.memory import InMemorySaver as _Saver
|
|
except ImportError: # pragma: no cover - older langgraph
|
|
from langgraph.checkpoint.memory import MemorySaver as _Saver
|
|
|
|
|
|
_P3_ENV = ("AGENT_TEAM_REPO_OWNER", "AGENT_TEAM_REPO_NAME")
|
|
_TOKEN_ENV = ("AGENT_TEAM_CI_READ_TOKEN", "GITHUB_TOKEN")
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_p3_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Start each test from a fully unset P3 environment."""
|
|
for name in (*_P3_ENV, *_TOKEN_ENV):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _p3_env_is_configured truth table
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_env_unset_is_not_configured(clean_p3_env: None) -> None:
|
|
assert _p3_env_is_configured() is False
|
|
|
|
|
|
def test_owner_repo_without_token_is_not_configured(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org")
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo")
|
|
# No CI-read token -> the verifier could never read an authenticated pass.
|
|
assert _p3_env_is_configured() is False
|
|
|
|
|
|
def test_token_without_owner_repo_is_not_configured(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_CI_READ_TOKEN", "ghp_test")
|
|
assert _p3_env_is_configured() is False
|
|
|
|
|
|
def test_owner_repo_and_ci_token_is_configured(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org")
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo")
|
|
monkeypatch.setenv("AGENT_TEAM_CI_READ_TOKEN", "ghp_test")
|
|
assert _p3_env_is_configured() is True
|
|
|
|
|
|
def test_github_token_fallback_satisfies_ci_token(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "org")
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo")
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_fallback")
|
|
assert _p3_env_is_configured() is True
|
|
|
|
|
|
def test_blank_env_values_are_not_configured(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Whitespace-only values must not count as configured (fail-closed).
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", " ")
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "repo")
|
|
monkeypatch.setenv("AGENT_TEAM_CI_READ_TOKEN", "ghp_test")
|
|
assert _p3_env_is_configured() is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# failsafe_production_p3_wiring — env-unset degrade
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_env_unset_returns_inert_pair_warns_and_notifies(
|
|
clean_p3_env: None, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
notices: list[str] = []
|
|
|
|
with caplog.at_level(logging.WARNING, logger="agent_team.coordinator"):
|
|
build_verify, dispatch = failsafe_production_p3_wiring(
|
|
notify=lambda msg, **_: notices.append(msg)
|
|
)
|
|
|
|
# Inert P3: no build->verify subgraph, no dispatch.
|
|
assert build_verify is None
|
|
assert dispatch is None
|
|
|
|
# Exactly one WARNING about the inert P3 wiring.
|
|
inert_warnings = [
|
|
r
|
|
for r in caplog.records
|
|
if r.levelno == logging.WARNING and "INERT" in r.getMessage()
|
|
]
|
|
assert len(inert_warnings) == 1
|
|
|
|
# Exactly one #agent-team inert notice via the (non-ALARM) notify sink.
|
|
assert len(notices) == 1
|
|
assert "INERT" in notices[0]
|
|
|
|
|
|
def test_env_unset_without_notify_does_not_raise(clean_p3_env: None) -> None:
|
|
# A token-less / channel-less serve still comes up inert with no notify sink.
|
|
build_verify, dispatch = failsafe_production_p3_wiring(notify=None)
|
|
assert build_verify is None
|
|
assert dispatch is None
|
|
|
|
|
|
def test_inert_notify_failure_is_swallowed(clean_p3_env: None) -> None:
|
|
def _boom(_msg: str, **_kw: Any) -> None:
|
|
raise RuntimeError("slack down")
|
|
|
|
# The notify sink raising must not propagate out of serve-start.
|
|
build_verify, dispatch = failsafe_production_p3_wiring(notify=_boom)
|
|
assert build_verify is None
|
|
assert dispatch is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# failsafe_production_p3_wiring — env-set binds live wiring
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_env_set_returns_live_wiring_pair(
|
|
clean_p3_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_OWNER", "test-org")
|
|
monkeypatch.setenv("AGENT_TEAM_REPO_NAME", "test-repo")
|
|
monkeypatch.setenv("AGENT_TEAM_CI_READ_TOKEN", "ghp_test")
|
|
|
|
notices: list[str] = []
|
|
build_verify, dispatch = failsafe_production_p3_wiring(
|
|
notify=lambda msg, **_: notices.append(msg)
|
|
)
|
|
|
|
# Live pair: both factories present, no inert notice emitted.
|
|
assert callable(build_verify)
|
|
assert callable(dispatch)
|
|
assert notices == []
|
|
|
|
# The dispatch factory is the env-reading production factory.
|
|
assert dispatch is default_dispatch_node_factory
|
|
|
|
# Each live factory builds without raising and yields the expected shapes.
|
|
bv_tuple = build_verify()
|
|
assert isinstance(bv_tuple, tuple) and len(bv_tuple) == 3
|
|
assert all(callable(part) for part in bv_tuple)
|
|
assert callable(dispatch())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Coordinator built with the inert result sets up + a P3-bound task does not
|
|
# dispatch / crash (it settles at the P2 BUILD terminus).
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_coordinator_with_inert_wiring_builds_and_task_parks_short_of_p3(
|
|
clean_p3_env: None, tmp_path: Path
|
|
) -> None:
|
|
"""env-unset -> graph builds, coordinator constructs, an approved task settles
|
|
at the P2 BUILD terminus with no P3 nodes, no dispatch, and no exception."""
|
|
from agent_team.graph import resume_task, start_task
|
|
from agent_team.nodes import review_loop
|
|
from agent_team.task_model import Phase, PipelineState, TaskStatus
|
|
|
|
build_verify, dispatch = failsafe_production_p3_wiring(notify=None)
|
|
assert (build_verify, dispatch) == (None, None)
|
|
|
|
saver = _Saver()
|
|
saved_invoker = review_loop._review_invoker
|
|
try:
|
|
review_loop.set_review_invoker(lambda prompt, **kw: "VERDICT: APPROVE\nok")
|
|
|
|
def _plan_stub(state: PipelineState) -> PipelineState:
|
|
return PipelineState(
|
|
plan={"title": "t", "scope": ["src"], "phases": ["P1"]},
|
|
current_phase=Phase.REVIEW.value,
|
|
status=TaskStatus.ACTIVE.value,
|
|
)
|
|
|
|
coord = Coordinator(
|
|
db_path=tmp_path / "inert.db",
|
|
transport=_FakeTransport(),
|
|
build_clarify_node=lambda: graph_mod.clarify_node,
|
|
build_plan_node=lambda: _plan_stub,
|
|
review_wiring=lambda: (
|
|
review_loop.bind_review_node(),
|
|
review_loop.route_after_review,
|
|
),
|
|
build_verify_wiring=build_verify,
|
|
dispatch_node_wiring=dispatch,
|
|
build_checkpointer=lambda _path: saver,
|
|
)
|
|
coord.setup()
|
|
|
|
# No P3 nodes were wired (inert): the graph stops at the P2 terminus.
|
|
nodes = coord.graph.get_graph().nodes
|
|
assert graph_mod.BUILD_NODE not in nodes
|
|
assert graph_mod.VERIFY_NODE not in nodes
|
|
|
|
# An approved task runs to the P2 BUILD terminus without dispatching or
|
|
# raising (it never reaches a live build/dispatch).
|
|
thread_id, _ = start_task(coord.graph, transport="slack")
|
|
final = resume_task(coord.graph, thread_id=thread_id, answer="scope is X")
|
|
assert final["current_phase"] == Phase.BUILD.value
|
|
finally:
|
|
review_loop._review_invoker = saved_invoker
|
|
|
|
|
|
class _FakeTransport:
|
|
"""Minimal non-posting transport for the inert-wiring coordinator test."""
|
|
|
|
def post_question(self, **_kwargs: Any) -> str:
|
|
return "ref"
|
|
|
|
def parse_answer(self, raw: Any) -> tuple[str, Any, str]: # pragma: no cover
|
|
raise NotImplementedError
|