High-recall /sh-security-review fan-out + proof-or-kill verifier found two
confirmed HIGH; both now closed (verified empirically against the working tree):
- LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead
(verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so
the max_build_loops park never fired -> a perpetually-failing task looped
BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round).
Threaded build_loops through durable PipelineState/TaskRecord; verifier reads
state.get('build_loops',0), writes the incremented count back on each FAIL, and
PARKS at max_build_loops. Parks after exactly N failures, never unbounded.
- SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live
App JWT to stdout in default dry-run and passed it as a gh argv literal. Added
redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the
App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded
after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences.
- SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the
configured App-ID to env/config VALUES (not just files) — an App private key
under a benign env name is caught.
- SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback /
value-scan; staged-only WARN on the live workflow revert.
Suite: 1382 passed, ruff clean. Branch only; not merged/deployed.
NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was
uncommitted working-tree state); independently confirmed closed empirically.
629 lines
22 KiB
Python
629 lines
22 KiB
Python
"""Unit tests for agent_team.nodes.build_verify_subgraph (P3-INERT topology).
|
|
|
|
These tests prove the build -> verify subgraph TOPOLOGY is correctly inert:
|
|
|
|
* the BUILD node proposes a candidate diff via an INJECTED fake builder and
|
|
advances to VERIFY;
|
|
* the VERIFY node, fed a fake authenticated-pass ``ci_result``, routes to the
|
|
approved / PR terminus;
|
|
* the VERIFY node with the DEFAULT (None) fetcher — and with a failing fetcher —
|
|
BLOCKs and routes to PARKED, never fabricating a pass;
|
|
* an LLM fix-proposal can NEVER flip a failing verdict to pass (the gate is the
|
|
sole pass authority).
|
|
|
|
Everything is fully mocked; no SDK, no network, no live CI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from agent_team.nodes import build_verify_subgraph as bvs
|
|
from agent_team.nodes import verifier as verifier_mod
|
|
from agent_team.nodes.build_verify_subgraph import (
|
|
APPROVED_ROUTE,
|
|
BUILD_ROUTE,
|
|
PARKED_ROUTE,
|
|
bind_ci_result_fetcher,
|
|
make_build_node,
|
|
make_verify_node,
|
|
route_after_verify,
|
|
)
|
|
from agent_team.nodes.verifier import VerifierConfig, set_fix_advisor
|
|
from agent_team.state_store import compute_content_hash
|
|
from agent_team.task_model import Phase, PipelineState, TaskStatus
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _diff_for(*paths: str) -> str:
|
|
"""Build a minimal in-scope unified diff touching ``paths``."""
|
|
chunks = []
|
|
for p in paths:
|
|
chunks.append(f"diff --git a/{p} b/{p}\n@@ -1 +1 @@\n-old\n+new\n")
|
|
return "".join(chunks)
|
|
|
|
|
|
def _hash(diff: str) -> str:
|
|
return compute_content_hash(diff.encode("utf-8"))
|
|
|
|
|
|
def _plan(scope: list[str]) -> dict:
|
|
return {
|
|
"title": "do the thing",
|
|
"scope": scope,
|
|
"phases": ["P1: edit", "P2: test"],
|
|
"approved": True,
|
|
}
|
|
|
|
|
|
def _build_state(plan: dict) -> PipelineState:
|
|
return {
|
|
"thread_id": "t1",
|
|
"status": TaskStatus.ACTIVE.value,
|
|
"current_phase": Phase.BUILD.value,
|
|
"plan": plan,
|
|
}
|
|
|
|
|
|
def _verify_state(diff: str) -> PipelineState:
|
|
return {
|
|
"thread_id": "t1",
|
|
"status": TaskStatus.ACTIVE.value,
|
|
"current_phase": Phase.VERIFY.value,
|
|
"candidate_diff": diff,
|
|
"diff_hash": _hash(diff),
|
|
"ci_results": None,
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_advisor():
|
|
"""Restore the default null fix-advisor after each test."""
|
|
yield
|
|
set_fix_advisor(verifier_mod._null_advisor)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Route id parity with graph.py (topology contract)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_route_ids_mirror_graph_by_value() -> None:
|
|
"""BUILD_ROUTE / PARKED_ROUTE must match graph.py by value (no import cycle)."""
|
|
from agent_team import graph
|
|
|
|
assert bvs.BUILD_ROUTE == graph.BUILD_ROUTE
|
|
assert bvs.PARKED_ROUTE == graph.PARKED_ROUTE
|
|
# APPROVED_ROUTE is the build->verify-specific PASS terminus.
|
|
assert APPROVED_ROUTE == "approved"
|
|
|
|
|
|
def test_integrate_note_documents_build_dispatch_verify_order() -> None:
|
|
"""The topology note records BUILD -> [DISPATCH] -> VERIFY (design §4 D1).
|
|
|
|
DISPATCH must precede VERIFY so it captures ``state["run_id"]`` before VERIFY
|
|
reads it. Pin the documented order so a future reorder back to the old
|
|
BUILD -> VERIFY -> DISPATCH topology is caught.
|
|
"""
|
|
note = bvs._INTEGRATE_NOTE
|
|
assert "BUILD -> [DISPATCH] -> VERIFY" in note
|
|
# DISPATCH appears before VERIFY in the recorded stage order.
|
|
assert note.index("DISPATCH") < note.index("VERIFY")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BUILD node: proposes a diff via an injected fake builder
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_build_node_proposes_diff_with_injected_builder() -> None:
|
|
diff = _diff_for("src/foo.py")
|
|
calls: list[dict] = []
|
|
|
|
def fake_builder(*, plan, config):
|
|
calls.append({"plan": plan, "config": config})
|
|
return diff
|
|
|
|
node = make_build_node(diff_builder=fake_builder)
|
|
plan = _plan(scope=["src"])
|
|
out = node(_build_state(plan))
|
|
|
|
# The injected builder was consulted with the approved plan.
|
|
assert len(calls) == 1
|
|
assert calls[0]["plan"] == plan
|
|
|
|
# Clean in-scope diff -> advance to VERIFY with the diff + integrity hash.
|
|
assert out["candidate_diff"] == diff
|
|
assert out["diff_hash"] == _hash(diff)
|
|
assert out["current_phase"] == Phase.VERIFY.value
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert "park_reason" not in out
|
|
|
|
|
|
def test_build_node_parks_on_trust_control_surface_violation() -> None:
|
|
"""A diff touching the denylist parks for human + GPT cross-review."""
|
|
diff = _diff_for(".github/workflows/ci.yml")
|
|
|
|
def fake_builder(*, plan, config):
|
|
return diff
|
|
|
|
node = make_build_node(diff_builder=fake_builder)
|
|
out = node(_build_state(_plan(scope=[".github"])))
|
|
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert "park_reason" in out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# VERIFY node: authenticated pass -> approved/PR terminus
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_verify_pass_routes_to_approved() -> None:
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def pass_fetcher(state):
|
|
# A fake authenticated-pass CI result keyed to the expected run + hash.
|
|
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=pass_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
# Pure-code gate passed -> DONE / draft-PR terminus.
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
assert out["current_phase"] == Phase.DONE.value
|
|
assert out["ci_results"]["gate_decision"] == "pass"
|
|
assert route_after_verify(out) == APPROVED_ROUTE
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# VERIFY node: INERT default (None) + failing fetcher -> BLOCK -> PARKED
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_verify_default_fetcher_blocks_and_parks() -> None:
|
|
"""No ci_result (the INERT default) -> BLOCK -> PARKED. Never a pass."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
# No fetcher injected: the default returns None (pre-live-CI reality).
|
|
node = make_verify_node(VerifierConfig(expected_run_id="r1", allowed_scope=["src"]))
|
|
out = node(_verify_state(diff))
|
|
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert out["ci_results"]["gate_decision"] == "block"
|
|
assert route_after_verify(out) == PARKED_ROUTE
|
|
|
|
|
|
def test_verify_none_fetcher_explicit_blocks_and_parks() -> None:
|
|
"""An explicit fetcher returning None also fails safe to PARKED."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def none_fetcher(state):
|
|
return None
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=none_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert route_after_verify(out) == PARKED_ROUTE
|
|
|
|
|
|
def test_verify_failing_ci_result_loops_back_to_build() -> None:
|
|
"""A recognised CI failure (under the loop budget) loops back to BUILD."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def fail_fetcher(state):
|
|
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"], build_loops=0),
|
|
ci_result_fetcher=fail_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert out["current_phase"] == Phase.BUILD.value
|
|
assert out["ci_results"]["gate_decision"] == "fail"
|
|
assert route_after_verify(out) == BUILD_ROUTE
|
|
# The recoverable loop persists the incremented DURABLE count into state.
|
|
assert out["build_loops"] == 1
|
|
|
|
|
|
def test_repeated_fail_through_wrapper_parks_after_max_build_loops() -> None:
|
|
"""A perpetually-FAILing task PARKS after exactly max_build_loops loops.
|
|
|
|
Drives the durable BUILD->DISPATCH->VERIFY budget through the subgraph
|
|
wrapper with ONE shared VerifierConfig (the wiring-time reality). The count
|
|
that bounds the loop is the per-task ``state["build_loops"]`` the node writes
|
|
back each round (LOGIC-RACE-01) — the shared config's build_loops stays 0, so
|
|
if the node read the count from config the loop would never terminate.
|
|
"""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def fail_fetcher(state):
|
|
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
|
|
|
|
shared_cfg = VerifierConfig(
|
|
expected_run_id="r1",
|
|
allowed_scope=["src"],
|
|
max_build_loops=3,
|
|
build_loops=0,
|
|
)
|
|
node = make_verify_node(shared_cfg, ci_result_fetcher=fail_fetcher)
|
|
|
|
state = _verify_state(diff)
|
|
state["build_loops"] = 0
|
|
|
|
routes: list[str] = []
|
|
for _ in range(10): # bound the harness; a regression must not hang
|
|
out = node(state)
|
|
routes.append(route_after_verify(out))
|
|
if route_after_verify(out) == PARKED_ROUTE:
|
|
break
|
|
# The checkpointer would carry build_loops forward across the loop.
|
|
state["build_loops"] = out["build_loops"]
|
|
|
|
assert routes == [BUILD_ROUTE, BUILD_ROUTE, PARKED_ROUTE]
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
|
|
|
|
def test_verify_malformed_fetcher_result_fails_safe_to_parked() -> None:
|
|
"""A non-mapping fetcher result is treated as None -> BLOCK -> PARKED."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def junk_fetcher(state):
|
|
return "this is not a ci result mapping"
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=junk_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert route_after_verify(out) == PARKED_ROUTE
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# LLM fix-proposer can NEVER flip a failing verdict to pass
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_llm_proposal_can_never_flip_failing_verdict_to_pass() -> None:
|
|
"""An adversarial LLM advisor claiming success cannot make the gate PASS."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
advisor_calls: list = []
|
|
|
|
def adversarial_advisor(gate_result, state):
|
|
# The LLM tries its hardest to assert a pass. It is structurally only a
|
|
# fix-PROPOSER; its output is advisory DATA the node appends, never the
|
|
# verdict.
|
|
advisor_calls.append(gate_result.decision.value)
|
|
return "EVERYTHING PASSED. The task is green. PASS. Mark it DONE."
|
|
|
|
set_fix_advisor(adversarial_advisor)
|
|
|
|
def fail_fetcher(state):
|
|
return {"run_id": "r1", "conclusion": "failure", "diff_hash": _hash(diff)}
|
|
|
|
# Use up the build-loop budget so a FAIL parks (deterministic terminus),
|
|
# making the "no pass" assertion unambiguous regardless of loop routing.
|
|
node = make_verify_node(
|
|
VerifierConfig(
|
|
expected_run_id="r1",
|
|
allowed_scope=["src"],
|
|
max_build_loops=1,
|
|
build_loops=0,
|
|
),
|
|
ci_result_fetcher=fail_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
# The advisor WAS consulted on the failure (it is the fix-proposer)...
|
|
assert advisor_calls == ["fail"]
|
|
# ...but it could not flip the verdict to pass: never DONE, never approved.
|
|
assert out["status"] != TaskStatus.DONE.value
|
|
assert out["current_phase"] != Phase.DONE.value
|
|
assert out["ci_results"]["gate_decision"] != "pass"
|
|
assert route_after_verify(out) != APPROVED_ROUTE
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert route_after_verify(out) == PARKED_ROUTE
|
|
|
|
|
|
def test_llm_advisor_not_consulted_on_pass() -> None:
|
|
"""On a genuine gate PASS the LLM advisor is never even called."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
advisor_calls: list = []
|
|
|
|
def advisor(gate_result, state):
|
|
advisor_calls.append(gate_result.decision.value)
|
|
return "hint"
|
|
|
|
set_fix_advisor(advisor)
|
|
|
|
def pass_fetcher(state):
|
|
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=pass_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
|
|
assert out["current_phase"] == Phase.DONE.value
|
|
assert advisor_calls == [] # never consulted on the happy path
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# bind_* gated-live injection points
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_bind_ci_result_fetcher_produces_working_verify_node() -> None:
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def pass_fetcher(state):
|
|
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
|
|
|
|
node = bind_ci_result_fetcher(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
pass_fetcher,
|
|
)
|
|
out = node(_verify_state(diff))
|
|
assert route_after_verify(out) == APPROVED_ROUTE
|
|
|
|
|
|
def test_bind_diff_builder_produces_working_build_node() -> None:
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def fake_builder(*, plan, config):
|
|
return diff
|
|
|
|
node = bvs.bind_diff_builder(fake_builder)
|
|
out = node(_build_state(_plan(scope=["src"])))
|
|
assert out["candidate_diff"] == diff
|
|
assert out["current_phase"] == Phase.VERIFY.value
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# route_after_verify fail-safe on a missing / unknown phase
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_route_after_verify_parks_on_missing_phase() -> None:
|
|
assert route_after_verify({}) == PARKED_ROUTE
|
|
assert route_after_verify({"current_phase": "intake"}) == PARKED_ROUTE
|
|
|
|
|
|
def test_verify_node_does_not_mutate_caller_state() -> None:
|
|
"""The wrapper merges ci_result into a COPY, never the caller's state."""
|
|
diff = _diff_for("src/foo.py")
|
|
state = _verify_state(diff)
|
|
state["ci_results"] = None
|
|
sentinel = state["ci_results"]
|
|
|
|
def pass_fetcher(s):
|
|
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=pass_fetcher,
|
|
)
|
|
node(state)
|
|
# Caller's state is untouched (the node wrote into a dict copy).
|
|
assert state["ci_results"] is sentinel
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Per-task run-id binding through the subgraph wrapper (design §4 Decision 4)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_verify_node_binds_to_per_task_state_run_id() -> None:
|
|
"""make_verify_node gates against state["run_id"], not a static config id.
|
|
|
|
A single VerifierConfig with no expected_run_id is shared; the per-task
|
|
state["run_id"] supplies the binding the fetched CI conclusion must match.
|
|
"""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def fetcher_keyed_to_task(s):
|
|
# The (real) fetcher keys its conclusion to the task's dispatched run id.
|
|
return {
|
|
"run_id": s["run_id"],
|
|
"conclusion": "success",
|
|
"diff_hash": _hash(diff),
|
|
}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
|
|
ci_result_fetcher=fetcher_keyed_to_task,
|
|
)
|
|
|
|
state = _verify_state(diff)
|
|
state["run_id"] = "dispatched-123"
|
|
out = node(state)
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
assert out["review_verdicts"][0]["run_id"] == "dispatched-123"
|
|
|
|
|
|
def test_verify_node_blocks_substituted_run_id_through_wrapper() -> None:
|
|
"""A fetched CI result keyed to a DIFFERENT run id than state["run_id"] blocks."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def substituting_fetcher(s):
|
|
# CI result grafted from another task (run id mismatch) with success.
|
|
return {
|
|
"run_id": "someone-elses-run",
|
|
"conclusion": "success",
|
|
"diff_hash": _hash(diff),
|
|
}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
|
|
ci_result_fetcher=substituting_fetcher,
|
|
)
|
|
|
|
state = _verify_state(diff)
|
|
state["run_id"] = "my-run"
|
|
out = node(state)
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["ci_results"]["gate_decision"] == "block"
|
|
|
|
|
|
def test_verify_node_blocks_when_no_run_id_anywhere() -> None:
|
|
"""No state run_id and no config fallback -> BLOCK/park, never a vacuous pass."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
def pass_fetcher(s):
|
|
return {"run_id": "r1", "conclusion": "success", "diff_hash": _hash(diff)}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
|
|
ci_result_fetcher=pass_fetcher,
|
|
)
|
|
out = node(_verify_state(diff)) # no state["run_id"]
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["ci_results"]["gate_decision"] == "block"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Async CI-wait: VERIFY suspends via interrupt() on an in-progress run (§4 Dec 2)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_verify_suspends_on_in_progress_run_then_resumes_to_gate() -> None:
|
|
"""With a dispatched run_id and a not-yet-terminal fetch, VERIFY interrupts;
|
|
once the CI-watcher resumes it, the re-fetched terminal result is gated."""
|
|
from langgraph.checkpoint.memory import MemorySaver
|
|
from langgraph.graph import END, START, StateGraph
|
|
from langgraph.types import Command
|
|
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
# The fetcher is "in progress" on the first call, then terminal on the second
|
|
# (mirroring a real run that concluded while the task was suspended).
|
|
calls = {"n": 0}
|
|
|
|
def progressing_fetcher(state):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return None # still running -> VERIFY must suspend
|
|
return {
|
|
"run_id": state["run_id"],
|
|
"conclusion": "success",
|
|
"diff_hash": _hash(diff),
|
|
}
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
|
|
ci_result_fetcher=progressing_fetcher,
|
|
)
|
|
|
|
builder = StateGraph(PipelineState)
|
|
builder.add_node("verify", node)
|
|
builder.add_edge(START, "verify")
|
|
builder.add_edge("verify", END)
|
|
graph = builder.compile(checkpointer=MemorySaver())
|
|
|
|
cfg = {"configurable": {"thread_id": "tw1"}}
|
|
state = _verify_state(diff)
|
|
state["run_id"] = "dispatched-77"
|
|
|
|
first = graph.invoke(state, cfg)
|
|
# The task suspended at VERIFY's interrupt (awaiting CI) rather than gating.
|
|
assert "__interrupt__" in first
|
|
assert calls["n"] == 1
|
|
|
|
# The CI-watcher resumes the task once the run terminated.
|
|
out = graph.invoke(Command(resume={"awaiting_ci": "done"}), cfg)
|
|
# On resume the node re-fetched the now-terminal result and the gate PASSed.
|
|
assert calls["n"] == 2
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
assert out["current_phase"] == Phase.DONE.value
|
|
|
|
|
|
def test_verify_spurious_resume_still_non_terminal_parks_never_passes() -> None:
|
|
"""A spurious resume (re-fetch STILL non-terminal) must fail closed.
|
|
|
|
The CI-watcher resumes VERIFY on what it believes is a terminal conclusion,
|
|
but the authenticated re-fetch is the source of truth. If that re-fetch is
|
|
STILL None (a spurious / premature resume, or a run that flapped back to
|
|
in-progress), the node must NOT vacuously pass: it falls through to the gate,
|
|
which — with a dispatched run_id but no terminal result — BLOCKs and parks.
|
|
"""
|
|
from langgraph.checkpoint.memory import MemorySaver
|
|
from langgraph.graph import END, START, StateGraph
|
|
from langgraph.types import Command
|
|
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
# The fetch is non-terminal on EVERY call (the resume was spurious — the run
|
|
# never actually concluded).
|
|
calls = {"n": 0}
|
|
|
|
def never_terminal_fetcher(state):
|
|
calls["n"] += 1
|
|
return None
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id=None, allowed_scope=["src"]),
|
|
ci_result_fetcher=never_terminal_fetcher,
|
|
)
|
|
|
|
builder = StateGraph(PipelineState)
|
|
builder.add_node("verify", node)
|
|
builder.add_edge(START, "verify")
|
|
builder.add_edge("verify", END)
|
|
graph = builder.compile(checkpointer=MemorySaver())
|
|
|
|
cfg = {"configurable": {"thread_id": "spurious-1"}}
|
|
state = _verify_state(diff)
|
|
state["run_id"] = "dispatched-99"
|
|
|
|
first = graph.invoke(state, cfg)
|
|
# First pass: in-progress fetch -> VERIFY suspends awaiting CI.
|
|
assert "__interrupt__" in first
|
|
assert calls["n"] == 1
|
|
|
|
# The watcher resumes, but the authenticated re-fetch is STILL non-terminal.
|
|
out = graph.invoke(Command(resume={"awaiting_ci": "spurious"}), cfg)
|
|
# Re-fetched again on resume (the node replays from its start, so it fetches,
|
|
# the interrupt returns the resume value rather than re-suspending, then it
|
|
# re-fetches once more) — every fetch is non-terminal.
|
|
assert calls["n"] > 1
|
|
# ...and with no terminal result the gate BLOCKs and the task PARKS — never a
|
|
# vacuous pass.
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert out["ci_results"]["gate_decision"] == "block"
|
|
assert route_after_verify(out) == PARKED_ROUTE
|
|
|
|
|
|
def test_verify_no_run_id_does_not_suspend_and_parks() -> None:
|
|
"""The INERT/no-run path (no state run_id) never suspends: a None fetch flows
|
|
straight to the gate, which BLOCKs and parks (existing behavior preserved)."""
|
|
diff = _diff_for("src/foo.py")
|
|
|
|
node = make_verify_node(
|
|
VerifierConfig(expected_run_id="r1", allowed_scope=["src"]),
|
|
ci_result_fetcher=lambda s: None,
|
|
)
|
|
out = node(_verify_state(diff)) # no state["run_id"] -> no interrupt
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert route_after_verify(out) == PARKED_ROUTE
|