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/agent_team/nodes/build_verify_subgraph.py
Adam Moussa f0c5cfe57f feat(agent-team): P3 Phase-0 box-side build->dispatch->verify (WIP)
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.
2026-06-23 19:52:04 -04:00

370 lines
19 KiB
Python

"""P3-INERT build -> verify subgraph TOPOLOGY (design §3.3, §7.1 P3).
This module is the **wiring topology** for the Plane-2 build -> verify stage::
... -> REVIEW (route "build") -> BUILD -> [DISPATCH] -> VERIFY
-> {approved | build | parked}
DISPATCH is the optional CI-trigger vertex :func:`agent_team.graph.build_graph`
splices between BUILD and VERIFY when a dispatch node is wired (design §4
Decision 1): it triggers the org CI run and captures ``state["run_id"]`` BEFORE
VERIFY reads it. With no dispatch node the order is simply ``BUILD -> VERIFY``.
It produces the BUILD node, the VERIFY node, and the
:func:`route_after_verify` conditional-edge function so the Integrate phase can
hang them off :func:`agent_team.graph.build_graph` as a subgraph reachable from
the review loop's ``"build"`` route. It assembles NOTHING by itself: it does not
call :func:`agent_team.graph.build_graph`, and the production default pipeline
stays P2 (clarify -> plan -> review). Hooking this subgraph in is a deliberate,
opt-in Integrate-phase edit.
============================== INERT / HARD-GATE ==========================
P3 (builders + verifier) is HARD-GATED behind ``/sh-security-review`` + a
GPT-4.1 cross-review of the §3.3.2 CI apply/verify trust boundary BEFORE it goes
live. This module is TOPOLOGY + SEAMS ONLY and MUST stay INERT:
* **No live CI.** The VERIFY node consumes an INJECTED ``ci_result`` seam — a
fetcher callable that, given the task state, returns the authenticated CI
conclusion as DATA (exactly what :func:`agent_team.ci_gate.evaluate_ci_gate`
expects). The DEFAULT fetcher returns ``None`` (the current pre-live-CI
reality). It performs NO live CI dispatch, NO OIDC, NO network to GitHub
Actions, NO ``git``/patch apply, and NO filesystem mutation.
* **Fail-safe verdict.** With no ``ci_result`` (the default), the pure-code
gate (:mod:`agent_team.ci_gate`) returns ``BLOCK`` — there is no
authenticated pass to be had — and :func:`route_after_verify` routes the
task to PARKED. The pipeline NEVER fabricates a pass; the gate is the sole
pass authority.
* **LLM stays a fix-proposer.** The verifier's LLM seam
(:data:`agent_team.nodes.verifier.FixAdvisor`) is consulted ONLY on a
failure to author a fix hint. It is structurally incapable of flipping the
verdict to pass (the verdict is computed first, by the gate, and is never
read back from the proposer — see :mod:`agent_team.nodes.verifier_llm`).
The live apply/verify path (the gated wiring of a real diff builder + a real CI
result fetcher) is held for the separate security-review + cross-review gate and
is NOT shipped or enabled here. :func:`bind_diff_builder` and
:func:`bind_ci_result_fetcher` are the injection points a leaf will use to bind
those real seams once the gate clears.
============================================================================
What this module owns (topology + seams only):
* :data:`APPROVED_ROUTE` / :data:`BUILD_ROUTE` / :data:`PARKED_ROUTE` — the
route ids :func:`route_after_verify` returns. ``BUILD_ROUTE`` /
``PARKED_ROUTE`` mirror :data:`agent_team.graph.BUILD_ROUTE` /
:data:`agent_team.graph.PARKED_ROUTE` by VALUE so the conditional-edge map the
Integrate phase builds matches without this module importing ``graph`` (which
would be a wiring import cycle).
* :func:`make_build_node` — factory producing the single-argument BUILD node,
threading an injectable :class:`~agent_team.nodes.builders.DiffBuilder` into
:func:`agent_team.nodes.builders.builders_node`.
* :func:`make_verify_node` — factory producing the single-argument VERIFY node,
threading an injectable ``ci_result`` fetcher into
:func:`agent_team.nodes.verifier.verifier_node` (default fetcher -> ``None``).
* :func:`route_after_verify` — the LangGraph conditional-edge function that
reads the verdict the VERIFY node recorded and returns the next route id.
* :func:`bind_diff_builder` / :func:`bind_ci_result_fetcher` — the gated-live
injection points (held for the security gate).
It imports the committed node + foundation contracts verbatim and redefines none
of them. No SDK is imported at module top (deferred discipline mirroring
:func:`agent_team.graph.build_sqlite_checkpointer`); it is fully unit-testable
with no network.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from agent_team.nodes.builders import DiffBuilder, builders_node
from agent_team.nodes.verifier import VerifierConfig, verifier_node
from agent_team.task_model import Phase, PipelineState
__all__ = [
"APPROVED_ROUTE",
"BUILD_NODE",
"BUILD_ROUTE",
"CiResultFetcher",
"PARKED_ROUTE",
"VERIFY_NODE",
"bind_ci_result_fetcher",
"bind_diff_builder",
"make_build_node",
"make_verify_node",
"route_after_verify",
]
# --- Node names (graph vertices). ------------------------------------------
# Kept as constants so the Integrate-phase wiring references the subgraph
# vertices by name rather than by string literal.
BUILD_NODE = "build"
VERIFY_NODE = "verify"
# --- Route ids returned by route_after_verify. -----------------------------
# These mirror agent_team.graph.BUILD_ROUTE / PARKED_ROUTE by VALUE so the
# conditional-edge map the Integrate phase builds lines up without importing
# graph here (that would be a wiring import cycle). APPROVED_ROUTE is the
# build->verify-specific PASS terminus (the draft-PR endpoint); BUILD_ROUTE is
# the loop-back to the builders on a recoverable failure; PARKED_ROUTE is the
# fail-safe escalation (the only reachable route while INERT, since the default
# fetcher yields no authenticated pass).
APPROVED_ROUTE = "approved"
BUILD_ROUTE = "build"
PARKED_ROUTE = "parked"
# The injectable CI-result seam: given the task state, return the authenticated,
# patch-independent CI conclusion as a mapping (run_id / conclusion / diff_hash),
# or ``None`` when there is no authenticated result. The DEFAULT
# (:func:`_no_ci_result`) always returns ``None`` (the INERT pre-live-CI
# reality), so the gate BLOCKs and the task parks — never a fabricated pass. The
# real fetcher (read-only PAT against the GitHub Checks/Actions API) is bound via
# :func:`bind_ci_result_fetcher` only after the §3.3.2 trust boundary clears its
# security gate.
CiResultFetcher = Callable[[PipelineState], Mapping[str, Any] | None]
def _no_ci_result(state: PipelineState) -> None:
"""Default :data:`CiResultFetcher`: there is NO authenticated CI result.
This is the INERT, pre-live-CI reality. Returning ``None`` means the
pure-code gate (:func:`agent_team.ci_gate.evaluate_ci_gate`) has no
authenticated conclusion to read and therefore returns ``BLOCK`` — never a
pass. The subgraph thus fails SAFE to PARKED until a real fetcher is bound
via :func:`bind_ci_result_fetcher` (which is held for the security gate).
"""
return None
def make_build_node(
*,
diff_builder: DiffBuilder | None = None,
config: Mapping[str, Any] | None = None,
) -> Callable[[PipelineState], dict[str, Any]]:
"""Produce the single-argument BUILD node (approved plan -> candidate diff).
Wraps :func:`agent_team.nodes.builders.builders_node` as a one-argument
``PipelineState -> partial PipelineState`` closure so LangGraph can add it as
a vertex without seeing the node's ``builder`` / ``config`` keyword params
(LangGraph would otherwise try to inject its own ``RunnableConfig`` there —
the same hazard :func:`agent_team.nodes.review_loop.bind_review_node`
guards against). The injected ``diff_builder`` is threaded straight to the
node's :class:`~agent_team.nodes.builders.DiffBuilder` seam.
INERT: when ``diff_builder`` is ``None`` the node falls back to its committed
default (:func:`agent_team.nodes.builders.default_diff_builder`), which fails
LOUDLY in an un-wired environment (the billing seam raises until configured)
rather than emitting an empty diff. The real DeepSeek path is bound via
:func:`bind_diff_builder` once the §3.3.2 gate clears. The node itself never
applies a patch — it emits the diff as DATA plus the box-side
trust-control-surface scan + integrity hash.
"""
def node(state: PipelineState) -> dict[str, Any]:
return builders_node(state, builder=diff_builder, config=config)
return node
def make_verify_node(
config: VerifierConfig,
*,
ci_result_fetcher: CiResultFetcher | None = None,
) -> Callable[[PipelineState], PipelineState]:
"""Produce the single-argument VERIFY node (gate the CI result, decide phase).
Wraps :func:`agent_team.nodes.verifier.verifier_node` as a one-argument
closure over ``config`` (a :class:`~agent_team.nodes.verifier.VerifierConfig`)
so it wires straight into LangGraph without a manage-injected ``config``
param. The INERT ``ci_result`` seam is the key here: the node reads its CI
conclusion from ``state["ci_results"]``, so this wrapper FETCHES that result
via the injected ``ci_result_fetcher`` and merges it into the state BEFORE
delegating to the node.
The default fetcher (:func:`_no_ci_result`) returns ``None`` (the pre-live-CI
reality). With no authenticated CI result the pure-code gate returns
``BLOCK`` and the node parks the task — it can NEVER fabricate a pass. The
LLM verifier seam stays a fix-PROPOSER only (the gate is the sole pass
authority); see :mod:`agent_team.nodes.verifier_llm`.
The fetcher is called defensively: it receives the task state and returns the
authenticated CI conclusion mapping (``run_id`` / ``conclusion`` /
``diff_hash``) or ``None``. Any value other than a mapping is treated as
"no result" (``None``), so a malformed fetcher fails SAFE to BLOCK rather
than smuggling something past the gate. The real read-only-PAT fetcher is
bound via :func:`bind_ci_result_fetcher` only after the §3.3.2 trust boundary
clears its security gate.
PER-TASK run-id binding (design §4 Decision 4): the expected run id the gate
binds the verdict to is NOT baked into ``config`` at factory time. The
dispatch node persists the run id THIS task dispatched as ``state["run_id"]``,
and :func:`agent_team.nodes.verifier.verifier_node` reads it from the state
threaded through below (``config.expected_run_id`` is only a static fallback
for harnesses with no per-task run id). So a single ``config`` shared across
tasks still gates each task against its OWN dispatched run, and a task whose
dispatch left no run id BLOCKs — never a vacuous pass.
ASYNC CI-WAIT (design §4 Decision 2): a CI apply/verify run takes ~7 minutes,
and a multi-minute *blocking* fetch here would stall the coordinator daemon's
tick loop and every other task. So when there IS a dispatched run to wait for
(``state["run_id"]`` is set) but the fetch yields no terminal result yet (the
run is still in progress → ``None``), the node SUSPENDS via
:func:`~langgraph.types.interrupt` — exactly the durable suspend/resume shape
the clarify human-gate node uses. The CI-watcher sweep
(:func:`agent_team.ci_watcher.run_ci_watcher`) polls the run read-only and
RESUMES this node once the run reaches a terminal conclusion; on resume the
node RE-FETCHES the now-terminal result and the pure-code gate decides. If the
re-fetched result is still not terminal (e.g. a spurious resume), the node
falls through to the gate, which BLOCKs/parks — fail-closed, never a vacuous
pass. With NO dispatched run (``state["run_id"]`` absent — the INERT path or a
harness), the node does NOT suspend: a ``None`` fetch flows straight to the
gate, which BLOCKs and parks exactly as before.
"""
fetcher: CiResultFetcher = (
ci_result_fetcher if ci_result_fetcher is not None else _no_ci_result
)
def node(state: PipelineState) -> PipelineState:
ci_result = _fetch_ci_result(fetcher, state)
# Async CI-wait: only when a run was actually dispatched (state["run_id"]
# is set) AND it has no terminal result yet do we suspend, so the daemon
# never blocks on an in-progress run. The CI-watcher resumes us on a
# terminal conclusion; we re-fetch once after resume. The INERT/no-run
# path (no run_id) skips this and lets the gate BLOCK/park as before.
run_id = state.get("run_id")
if ci_result is None and isinstance(run_id, str) and run_id:
# Suspend + checkpoint; the CI-watcher's resume payload is the signal
# that the run terminated. We do not trust the payload's contents —
# we RE-FETCH the authenticated conclusion below so the gate reads a
# patch-independent, freshly-fetched result, never a resume-supplied
# one.
_await_ci(run_id)
ci_result = _fetch_ci_result(fetcher, state)
# Merge the (possibly still-None) fetched CI result into the state the
# node reads from, WITHOUT mutating the caller's state object. The node
# reads ``ci_results``; a None result leaves the gate with nothing to pass
# on (it BLOCKs → park), so a never-terminal run fails closed.
scoped_state: dict[str, Any] = dict(state)
scoped_state["ci_results"] = ci_result
return verifier_node(scoped_state, config)
return node
def _fetch_ci_result(
fetcher: CiResultFetcher, state: PipelineState
) -> Mapping[str, Any] | None:
"""Call the injected CI fetcher and normalise its result.
Any value other than a mapping is treated as "no terminal result" (``None``),
so a malformed fetcher fails SAFE (the gate BLOCKs) rather than smuggling a
non-mapping past the gate.
"""
fetched = fetcher(state)
return fetched if isinstance(fetched, Mapping) else None
def _await_ci(run_id: str) -> None:
"""Suspend the VERIFY node until the CI-watcher resumes it (§4 Decision 2).
Mirrors the clarify human-gate node's durable suspend: calls
:func:`langgraph.types.interrupt` so the graph checkpoints and the daemon's
tick loop is freed while a multi-minute CI run is in flight. The
:func:`agent_team.ci_watcher.run_ci_watcher` sweep polls the run read-only and
drives the resume once it terminates. The interrupt payload carries only the
``run_id`` being awaited (provenance for the watcher / operator logs); the
resume VALUE is intentionally ignored — the node re-fetches the authenticated
conclusion so the gate never reads a resume-supplied verdict.
``langgraph`` is imported lazily here to preserve this module's "no SDK at
module top" discipline (the topology stays importable where ``langgraph`` is
absent; the interrupt is only reached on the live, dispatched path).
"""
from langgraph.types import interrupt
interrupt({"awaiting_ci": True, "run_id": run_id})
def route_after_verify(state: PipelineState) -> str:
"""LangGraph conditional-edge: the next route id after the VERIFY node.
Reads the phase the VERIFY node recorded (the pure-code gate's verdict,
already merged into ``current_phase`` / ``status``) and maps it to a route
id the Integrate-phase conditional-edge map keys against:
* gate PASS -> phase ``DONE`` -> :data:`APPROVED_ROUTE` (the draft-PR
terminus). UNREACHABLE while INERT — the default fetcher yields no
authenticated pass, so the gate never returns PASS.
* gate FAIL under the build-loop budget -> phase ``BUILD`` ->
:data:`BUILD_ROUTE` (loop back to the builders with the fix hint).
* gate BLOCK, or FAIL at/over the budget -> phase ``PARKED`` ->
:data:`PARKED_ROUTE` (escalate to human + GPT cross-review; ALARM).
FAILS SAFE: any unexpected / missing phase routes to
:data:`PARKED_ROUTE` rather than advancing, so an ambiguous state parks for a
human instead of shipping. The verdict is owned entirely by the gate (the
node already applied it); this function only reads the recorded phase.
"""
phase = state.get("current_phase")
if phase == Phase.DONE.value:
return APPROVED_ROUTE
if phase == Phase.BUILD.value:
return BUILD_ROUTE
if phase == Phase.PARKED.value:
return PARKED_ROUTE
# Unknown / missing phase (the node always sets one of the above) -> park
# fail-closed rather than advancing an ambiguous state.
return PARKED_ROUTE
def bind_diff_builder(
diff_builder: DiffBuilder,
) -> Callable[[PipelineState], dict[str, Any]]:
"""Bind the REAL diff builder into a BUILD node (GATED-LIVE injection point).
Thin convenience over :func:`make_build_node` for the leaf that, once the
§3.3.2 trust boundary clears ``/sh-security-review`` + the GPT-4.1
cross-review, binds the real DeepSeek ``fast_coder`` path (adapt
:func:`agent_team.nodes.builders_llm.as_diff_builder` into a
:class:`~agent_team.nodes.builders.DiffBuilder`). Binding it does NOT enable
any apply/verify behaviour — the BUILD node still only EMITS a diff as DATA
plus the box-side scan + hash. Held for the gate; not wired here.
"""
return make_build_node(diff_builder=diff_builder)
def bind_ci_result_fetcher(
config: VerifierConfig,
ci_result_fetcher: CiResultFetcher,
) -> Callable[[PipelineState], PipelineState]:
"""Bind the REAL CI-result fetcher into a VERIFY node (GATED-LIVE injection).
Thin convenience over :func:`make_verify_node` for the leaf that, once the
§3.3.2 trust boundary clears its security gate, binds the real authenticated
CI-result fetcher (read-only PAT against the GitHub Checks/Actions API, NOT
enabled here). The fetcher returns the authenticated conclusion as DATA;
pass/fail remains owned by the pure-code gate, so binding a fetcher only
GIVES the gate a result to read — it can never make the LLM the pass
authority. Held for the gate; not wired here.
"""
return make_verify_node(config, ci_result_fetcher=ci_result_fetcher)
# A module-level note for the Integrate phase (no execution): the build->verify
# subgraph is hung off the review loop's "build" route. The linear stage order is
# BUILD -> [DISPATCH] -> VERIFY — DISPATCH (the optional CI-trigger vertex
# build_graph splices in when a dispatch node is wired) fires the CI run and
# captures ``state["run_id"]`` BEFORE VERIFY reads it (design §4 Decision 1); with
# no dispatch node the order is just BUILD -> VERIFY. The conditional-edge map
# from VERIFY should send APPROVED_ROUTE to the PR/draft terminus, BUILD_ROUTE
# back to the BUILD node (the bounded build<->verify loop, capped by
# VerifierConfig.max_build_loops), and PARKED_ROUTE to the escalation terminus.
# build_graph wires this in opt-in; this module never assembles it itself.
_INTEGRATE_NOTE = (
"review('build') -> BUILD -> [DISPATCH] -> VERIFY -> route_after_verify -> "
"{approved: PR terminus, build: BUILD (loop), parked: escalation}"
)