Pins the invariant behind the CRITICAL gate-delivery gap this PR fixed: a new interrupt kind added to the pending_questions CHECK without a coordinator branch suspends the task forever (no Slack post, no ledger row). The guard parses the allowed kinds from PENDING_QUESTIONS_DDL (source of truth) and asserts every non-default kind has a graph.*_KIND constant referenced in coordinator.py. Negative-checked: removing the confluence_approval branch fails the guard.
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
"""Guard: every ``pending_questions`` ``kind`` has a coordinator delivery path.
|
|
|
|
A human-gate interrupt ``kind`` is NOT delivered for free. The responder/notify
|
|
path is not kind-agnostic: a ``kind`` added to the ``pending_questions`` CHECK
|
|
without a coordinator branch makes the task suspend FOREVER — no Slack post, no
|
|
durable ledger row, the human can never answer. That was a real CRITICAL gap
|
|
when ``confluence_approval`` was first wired (the node + graph were complete but
|
|
the coordinator only branched on ``plan_decision``). See the memory
|
|
``reference_agent_team_extension_gotchas``.
|
|
|
|
This test pins the invariant from the schema (the single source of truth for
|
|
allowed kinds): every non-default kind must have a ``*_KIND`` constant in
|
|
``agent_team.graph`` AND an explicit reference to it in ``coordinator.py`` (its
|
|
delivery branch). A new kind therefore cannot ship without its delivery wiring.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from agent_team import coordinator as coordinator_mod
|
|
from agent_team import graph as graph_mod
|
|
from agent_team.db import schema as schema_mod
|
|
|
|
# ``clarify`` is delivered by the DEFAULT notify path (the fallthrough in
|
|
# ``_post_resume_followups`` / ``start_task``), so it needs no dedicated branch.
|
|
# Every OTHER kind must be keyed on its own ``graph.*_KIND`` constant.
|
|
_DEFAULT_DELIVERED_KINDS = {"clarify"}
|
|
|
|
|
|
def _declared_kinds() -> set[str]:
|
|
"""The kind values the ``pending_questions`` CHECK allows (source of truth)."""
|
|
match = re.search(r"kind IN \(([^)]*)\)", schema_mod.PENDING_QUESTIONS_DDL)
|
|
assert match, (
|
|
"could not find the pending_questions kind CHECK in PENDING_QUESTIONS_DDL"
|
|
)
|
|
return set(re.findall(r"'([^']+)'", match.group(1)))
|
|
|
|
|
|
def _kind_constant_names() -> dict[str, str]:
|
|
"""Map each kind VALUE to the ``graph.*_KIND`` constant NAME that holds it."""
|
|
out: dict[str, str] = {}
|
|
for name in dir(graph_mod):
|
|
if name.endswith("_KIND"):
|
|
value = getattr(graph_mod, name)
|
|
if isinstance(value, str):
|
|
out[value] = name
|
|
return out
|
|
|
|
|
|
def test_declared_kinds_parse_sane() -> None:
|
|
# Guards the regex itself: if PENDING_QUESTIONS_DDL is reshaped and the CHECK
|
|
# stops parsing, this fails loudly instead of silently passing an empty set.
|
|
kinds = _declared_kinds()
|
|
assert {"clarify", "plan_decision"} <= kinds
|
|
|
|
|
|
def test_every_pending_question_kind_has_coordinator_delivery() -> None:
|
|
kinds = _declared_kinds()
|
|
constants = _kind_constant_names()
|
|
coordinator_src = Path(coordinator_mod.__file__).read_text()
|
|
|
|
for kind in sorted(kinds - _DEFAULT_DELIVERED_KINDS):
|
|
const = constants.get(kind)
|
|
assert const, (
|
|
f"pending_questions kind {kind!r} has no `*_KIND` constant in "
|
|
"agent_team.graph — define one and give it a coordinator delivery "
|
|
"branch (see reference_agent_team_extension_gotchas)."
|
|
)
|
|
assert f"graph_mod.{const}" in coordinator_src, (
|
|
f"pending_questions kind {kind!r} ({const}) has no delivery branch in "
|
|
"coordinator.py — a kind added to the CHECK without a coordinator "
|
|
"branch suspends the task forever (no Slack post, no ledger row). Add "
|
|
"a branch keyed on graph_mod." + (const or "<KIND>") + " in "
|
|
"_post_resume_followups, start_task, AND the redelivery sweep, and "
|
|
"make the interrupt payload carry a question_set "
|
|
"(see reference_agent_team_extension_gotchas)."
|
|
)
|