Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto
the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean.
Built (pre-deployment scaffold only — nothing provisioned/enabled):
- LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable)
- nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier
- §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder
- transports: slack / github / claude_code adapters
- ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness
- ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled
KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up):
- builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete)
- §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency
- operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap
- ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.)
- P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven
Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning,
/sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 14:17:44 -04:00
|
|
|
"""Unit tests for agent_team.graph (Plane-2 P1 LangGraph wiring; §3.3, §7.1).
|
|
|
|
|
|
|
|
|
|
These exercise the P1 skeleton + human gate wiring:
|
|
|
|
|
|
|
|
|
|
* the pure node functions (intake/clarify-author/plan) in isolation,
|
|
|
|
|
* graph assembly + edge topology,
|
|
|
|
|
* the suspend-on-interrupt / resume-with-Command mechanic end to end,
|
|
|
|
|
* the thread_id-keyed driver seam (start/resume/get_state/pending_question),
|
|
|
|
|
* that the foundation contracts (PipelineState / Phase / TaskStatus /
|
|
|
|
|
QuestionSet) are imported verbatim and round-trip through the wiring.
|
|
|
|
|
|
|
|
|
|
An in-memory checkpointer is injected (the SQLite checkpointer is the
|
|
|
|
|
production store, D9, not constructed in pre-deploy scaffolding).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# InMemorySaver is the modern name; fall back to MemorySaver on older langgraph.
|
|
|
|
|
try: # pragma: no cover - import shim
|
|
|
|
|
from langgraph.checkpoint.memory import InMemorySaver as _Saver
|
|
|
|
|
except ImportError: # pragma: no cover - import shim
|
|
|
|
|
from langgraph.checkpoint.memory import MemorySaver as _Saver
|
|
|
|
|
|
|
|
|
|
from agent_team import graph as graph_mod
|
|
|
|
|
from agent_team.graph import (
|
|
|
|
|
CLARIFY,
|
|
|
|
|
INTAKE,
|
|
|
|
|
P1_PHASE_SEQUENCE,
|
|
|
|
|
PLAN,
|
|
|
|
|
build_graph,
|
|
|
|
|
build_sqlite_checkpointer,
|
|
|
|
|
clarify_node,
|
|
|
|
|
get_pipeline_state,
|
|
|
|
|
intake_node,
|
|
|
|
|
pending_question,
|
|
|
|
|
plan_node,
|
|
|
|
|
plan_phase,
|
|
|
|
|
resume_task,
|
|
|
|
|
start_task,
|
|
|
|
|
thread_config,
|
|
|
|
|
)
|
|
|
|
|
from agent_team.task_model import Phase, PipelineState, TaskStatus
|
|
|
|
|
from agent_team.transport import QuestionSet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def compiled():
|
|
|
|
|
"""A graph compiled with a fresh in-memory checkpointer per test."""
|
|
|
|
|
return build_graph(checkpointer=_Saver())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Module surface / constants. -------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_node_name_constants_are_distinct() -> None:
|
|
|
|
|
assert len({INTAKE, CLARIFY, PLAN}) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_p1_phase_sequence_stops_at_plan() -> None:
|
|
|
|
|
# P1 ends at an approved plan — no BUILD/VERIFY in the wired sequence (§7.1).
|
|
|
|
|
assert P1_PHASE_SEQUENCE == (Phase.INTAKE, Phase.CLARIFY, Phase.PLAN)
|
|
|
|
|
assert Phase.BUILD not in P1_PHASE_SEQUENCE
|
|
|
|
|
assert Phase.VERIFY not in P1_PHASE_SEQUENCE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Pure node behaviour. ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_intake_node_activates_and_advances_to_clarify() -> None:
|
|
|
|
|
out = intake_node(PipelineState(thread_id="t", current_phase=Phase.INTAKE.value))
|
|
|
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
|
|
|
assert out["current_phase"] == Phase.CLARIFY.value
|
|
|
|
|
assert out["updated_at"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plan_node_lands_approved_plan_and_finishes() -> None:
|
|
|
|
|
out = plan_node(PipelineState(thread_id="t", qa_history=[{"answer": "x"}]))
|
|
|
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert out["current_phase"] == Phase.DONE.value
|
|
|
|
|
assert out["plan"]["approved"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plan_phase_counts_qa_turns() -> None:
|
|
|
|
|
state = PipelineState(qa_history=[{"answer": "a"}, {"answer": "b"}])
|
|
|
|
|
plan = plan_phase(state)
|
|
|
|
|
assert plan["qa_turns"] == 2
|
|
|
|
|
assert plan["approved"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plan_phase_handles_empty_history() -> None:
|
|
|
|
|
assert plan_phase(PipelineState())["qa_turns"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clarify_node_suspends_rather_than_falling_through() -> None:
|
|
|
|
|
# Called bare (no running graph), interrupt() refuses to return a value:
|
|
|
|
|
# it raises because there is no runnable context to suspend into. This
|
|
|
|
|
# confirms clarify_node genuinely suspends rather than falling through to
|
|
|
|
|
# its post-interrupt return.
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
clarify_node(PipelineState(thread_id="t", transport="slack"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Graph assembly. --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_graph_without_checkpointer_compiles() -> None:
|
|
|
|
|
# An uncheckpointed graph still compiles (used only for straight-through
|
|
|
|
|
# smoke paths); the driver requires a checkpointer for suspend/resume.
|
|
|
|
|
assert build_graph() is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_graph_with_checkpointer_compiles(compiled) -> None:
|
|
|
|
|
assert compiled is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_graph_nodes_present(compiled) -> None:
|
|
|
|
|
nodes = set(compiled.get_graph().nodes)
|
|
|
|
|
assert {INTAKE, CLARIFY, PLAN} <= nodes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Suspend / resume end to end. ------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_start_task_suspends_on_human_gate(compiled) -> None:
|
|
|
|
|
thread_id, state = start_task(compiled, transport="slack")
|
|
|
|
|
# The task ran INTAKE then suspended at CLARIFY's interrupt().
|
|
|
|
|
assert "__interrupt__" in state
|
|
|
|
|
payload = pending_question(compiled, thread_id=thread_id)
|
|
|
|
|
assert payload is not None
|
|
|
|
|
assert payload["thread_id"] == thread_id
|
|
|
|
|
assert payload["transport"] == "slack"
|
|
|
|
|
assert payload["turn"] == 0
|
|
|
|
|
assert payload["deadline"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_question_carries_foundation_questionset(compiled) -> None:
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
payload = pending_question(compiled, thread_id=thread_id)
|
|
|
|
|
qset = payload["question_set"]
|
|
|
|
|
# Verbatim foundation contract — not a redefinition.
|
|
|
|
|
assert isinstance(qset, QuestionSet)
|
|
|
|
|
assert qset.thread_id == thread_id
|
|
|
|
|
assert qset.question_id == payload["question_id"]
|
|
|
|
|
assert qset.turn == 0
|
|
|
|
|
assert qset.questions # non-empty question-set
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resume_drives_task_to_done(compiled) -> None:
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
final = resume_task(compiled, thread_id=thread_id, answer={"text": "do the thing"})
|
|
|
|
|
assert final["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert final["current_phase"] == Phase.DONE.value
|
|
|
|
|
assert final["plan"]["approved"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_answer_is_recorded_in_qa_history(compiled) -> None:
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
answer = {"text": "scope is X"}
|
|
|
|
|
final = resume_task(compiled, thread_id=thread_id, answer=answer)
|
|
|
|
|
assert len(final["qa_history"]) == 1
|
|
|
|
|
assert final["qa_history"][0]["answer"] == answer
|
|
|
|
|
assert final["qa_history"][0]["turn"] == 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 14:47:54 -04:00
|
|
|
def test_question_id_is_stable_across_resume(compiled) -> None:
|
|
|
|
|
# The clarifier node re-executes on resume; the question_id must NOT change
|
|
|
|
|
# between the id delivered at suspend (the ledger key) and the one recorded
|
|
|
|
|
# in qa_history, or the §3.3.1 identity contract breaks.
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
delivered = pending_question(compiled, thread_id=thread_id)["question_id"]
|
|
|
|
|
final = resume_task(compiled, thread_id=thread_id, answer="ok")
|
|
|
|
|
assert final["qa_history"][0]["question_id"] == delivered
|
|
|
|
|
|
|
|
|
|
|
Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto
the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean.
Built (pre-deployment scaffold only — nothing provisioned/enabled):
- LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable)
- nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier
- §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder
- transports: slack / github / claude_code adapters
- ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness
- ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled
KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up):
- builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete)
- §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency
- operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap
- ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.)
- P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven
Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning,
/sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 14:17:44 -04:00
|
|
|
def test_no_pending_question_after_completion(compiled) -> None:
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
resume_task(compiled, thread_id=thread_id, answer="ok")
|
|
|
|
|
assert pending_question(compiled, thread_id=thread_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_pipeline_state_reflects_suspend_then_done(compiled) -> None:
|
|
|
|
|
thread_id, _ = start_task(compiled, transport="slack")
|
|
|
|
|
mid = get_pipeline_state(compiled, thread_id=thread_id)
|
|
|
|
|
# Suspended ON the clarifier gate: INTAKE already advanced the phase to
|
|
|
|
|
# CLARIFY, and the clarifier's post-interrupt write (-> PLAN) has NOT yet
|
|
|
|
|
# committed because the node is paused at interrupt(). Task is mid-flight.
|
|
|
|
|
assert mid["current_phase"] == Phase.CLARIFY.value
|
|
|
|
|
assert mid["status"] == TaskStatus.ACTIVE.value
|
|
|
|
|
resume_task(compiled, thread_id=thread_id, answer="ok")
|
|
|
|
|
done = get_pipeline_state(compiled, thread_id=thread_id)
|
|
|
|
|
assert done["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert done["current_phase"] == Phase.DONE.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Thread isolation (§3.3.1 P1 exit criterion (d)). ----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_tasks_suspend_and_resume_independently(compiled) -> None:
|
|
|
|
|
t1, _ = start_task(compiled, transport="slack")
|
|
|
|
|
t2, _ = start_task(compiled, transport="github")
|
|
|
|
|
|
|
|
|
|
assert t1 != t2
|
|
|
|
|
p1 = pending_question(compiled, thread_id=t1)
|
|
|
|
|
p2 = pending_question(compiled, thread_id=t2)
|
|
|
|
|
assert p1["transport"] == "slack"
|
|
|
|
|
assert p2["transport"] == "github"
|
|
|
|
|
assert p1["question_id"] != p2["question_id"]
|
|
|
|
|
|
|
|
|
|
# Resume only t1; t2 must remain suspended on its own gate.
|
|
|
|
|
f1 = resume_task(compiled, thread_id=t1, answer="answer-1")
|
|
|
|
|
assert f1["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert pending_question(compiled, thread_id=t2) is not None
|
|
|
|
|
|
|
|
|
|
f2 = resume_task(compiled, thread_id=t2, answer="answer-2")
|
|
|
|
|
assert f2["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert f2["qa_history"][0]["answer"] == "answer-2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_thread_id_is_honoured(compiled) -> None:
|
|
|
|
|
tid, _ = start_task(compiled, thread_id="fixed-thread", transport="slack")
|
|
|
|
|
assert tid == "fixed-thread"
|
|
|
|
|
assert pending_question(compiled, thread_id="fixed-thread") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Durable resume across a fresh graph object (P1 exit criterion (a)). ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resume_works_on_a_new_graph_over_shared_checkpointer() -> None:
|
|
|
|
|
# Simulates a process restart: a NEW compiled graph object built over the
|
|
|
|
|
# SAME checkpointer must resume a task suspended by the first graph object.
|
|
|
|
|
saver = _Saver()
|
|
|
|
|
g1 = build_graph(checkpointer=saver)
|
|
|
|
|
thread_id, _ = start_task(g1, transport="slack")
|
|
|
|
|
|
|
|
|
|
g2 = build_graph(checkpointer=saver) # "after restart"
|
|
|
|
|
assert pending_question(g2, thread_id=thread_id) is not None
|
|
|
|
|
final = resume_task(g2, thread_id=thread_id, answer="post-restart")
|
|
|
|
|
assert final["status"] == TaskStatus.DONE.value
|
|
|
|
|
assert final["qa_history"][0]["answer"] == "post-restart"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Driver-seam helpers. ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_thread_config_shape() -> None:
|
|
|
|
|
assert thread_config("abc") == {"configurable": {"thread_id": "abc"}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_start_task_mints_unique_thread_ids(compiled) -> None:
|
|
|
|
|
t1, _ = start_task(compiled, transport="slack")
|
|
|
|
|
t2, _ = start_task(compiled, transport="slack")
|
|
|
|
|
assert t1 != t2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Production checkpointer factory. --------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_sqlite_checkpointer_missing_dep_raises_runtimeerror(
|
|
|
|
|
monkeypatch, tmp_path
|
|
|
|
|
) -> None:
|
|
|
|
|
# When the optional langgraph-checkpoint-sqlite package is absent, the
|
|
|
|
|
# factory must fail loudly with a clear RuntimeError, never silently run
|
|
|
|
|
# uncheckpointed. Force the ImportError path deterministically.
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
real_import = builtins.__import__
|
|
|
|
|
|
|
|
|
|
def _blocking_import(name, *args, **kwargs):
|
|
|
|
|
if name == "langgraph.checkpoint.sqlite":
|
|
|
|
|
raise ImportError("blocked for test")
|
|
|
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _blocking_import)
|
|
|
|
|
with pytest.raises(RuntimeError, match="SQLite checkpointer"):
|
|
|
|
|
build_sqlite_checkpointer(tmp_path / "state.db")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_sqlite_checkpointer_builds_when_dep_present(tmp_path) -> None:
|
|
|
|
|
# If the optional package IS installed, the factory returns a checkpointer
|
|
|
|
|
# over the DB path. Skip cleanly where it's absent (pre-deploy scaffolding).
|
|
|
|
|
pytest.importorskip("langgraph.checkpoint.sqlite")
|
|
|
|
|
saver = build_sqlite_checkpointer(tmp_path / "nested" / "state.db")
|
|
|
|
|
assert saver is not None
|
|
|
|
|
assert (tmp_path / "nested").is_dir()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Module import hygiene. -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_module_imports_without_optional_sqlite_dep() -> None:
|
|
|
|
|
# The module-level import of graph must not pull in the optional SQLite
|
|
|
|
|
# checkpointer (that import is deferred into build_sqlite_checkpointer).
|
|
|
|
|
assert hasattr(graph_mod, "build_graph")
|
|
|
|
|
assert hasattr(graph_mod, "build_sqlite_checkpointer")
|