ariadne/tests/test_hermes_autotriage_decision.py
codex 8c65b7bd60 feat(hermes-triage): retry_transient_infra action for connectivity failures
Second entry in the action registry, proving it is a real extension point.
No cluster mutation: the action is one Jenkins rebuild.

- hermes_infra_signals: reviewable marker set across DNS/connectivity,
  image pull, upstream 5xx and agent-channel loss; Ariadne independently
  confirms a marker in the evidence before any retry, and records which
  marker justified it. "no space left on device" is deliberately excluded
  because a retry lands on the same full volume.
- decision: classification -> action registry (action_classifications),
  falling back to the previous single-classification behavior
- repair: retry_build posts to /build for unparameterized real jobs and
  buildWithParameters for the fixture demo job
- orchestrator: retry path records requested/accepted/executed and moves
  the incident to awaiting_rebuild so the existing success path resolves
  it; one action per incident still enforced, so a retry cannot loop
- events layer split out of the orchestrator to stay under the LOC cap

Motivated by real failures tonight: pip DNS resolution and a Gitea 443
connect timeout, plus live incident metis/271 (SCM checkout timeout).

30 new tests; 368 pass in the hermes suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:42:48 -03:00

447 lines
16 KiB
Python

from __future__ import annotations
import json
import pytest
from ariadne.services import hermes_autotriage_decision as module
INCIDENT_ID = "inc-42"
def _payload(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"incident_id": INCIDENT_ID,
"classification": "known_demo_fixture_failure",
"confidence": 0.93,
"facts": [
{
"statement": "Build demo #12 failed in the fixture stage.",
"source": "jenkins",
"reference": "https://ci.bstein.dev/job/demo/12/",
}
],
"inferences": ["The fixture dataset drifted."],
"first_failed_gate": "unit-tests",
"requested_action": {"type": "run_ariadne_job", "id": "demo-fixture-reset"},
"human_required": False,
"reason": "Known fixture failure signature matched.",
}
base.update(overrides)
return base
def _parse(payload: dict | None = None, raw: str | None = None, incident: str = INCIDENT_ID): # type: ignore[no-untyped-def]
text = raw if raw is not None else json.dumps(payload if payload is not None else _payload())
return module.parse_triage_response(text, incident)
def _cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"autoremediation_enabled": True,
"allowed_actions": ["demo-fixture-reset"],
"min_confidence": 0.8,
"expected_classification": "known_demo_fixture_failure",
"max_actions_per_incident": 1,
}
base.update(overrides)
return base
def _authorize(outcome=None, cfg=None, **overrides): # type: ignore[no-untyped-def]
kwargs = {
"prior_action_count": 0,
"build_is_terminal_failure": True,
"job_allowlisted": True,
"evidence_has_signature": True,
}
kwargs.update(overrides)
return module.authorize_action(outcome if outcome is not None else _parse(), cfg if cfg is not None else _cfg(), **kwargs)
def test_parse_valid_response() -> None:
outcome = _parse()
assert outcome.valid is True
assert outcome.human_required is False
assert outcome.reject_reason is None
decision = outcome.decision
assert decision is not None
assert decision.incident_id == INCIDENT_ID
assert decision.classification == "known_demo_fixture_failure"
assert decision.confidence == 0.93
assert decision.facts == [
module.TriageFact(
statement="Build demo #12 failed in the fixture stage.",
source="jenkins",
reference="https://ci.bstein.dev/job/demo/12/",
)
]
assert decision.inferences == ["The fixture dataset drifted."]
assert decision.first_failed_gate == "unit-tests"
assert decision.requested_action == module.RequestedAction(type="run_ariadne_job", id="demo-fixture-reset")
assert decision.reason == "Known fixture failure signature matched."
def test_parse_accepts_markdown_fenced_json() -> None:
raw = "Here is my triage.\n```json\n" + json.dumps(_payload()) + "\n```\nLet me know."
outcome = _parse(raw=raw)
assert outcome.valid is True
assert outcome.decision is not None
assert outcome.decision.requested_action.id == "demo-fixture-reset"
def test_parse_accepts_prose_and_braces_inside_strings() -> None:
payload = _payload(reason='Matched the {fixture} signature with a "quoted" note and a \\ escape.')
raw = "Prose before } stray brace... " + json.dumps(payload) + ' Trailing prose {"not": "parsed"}'
outcome = _parse(raw=raw)
assert outcome.valid is True
assert outcome.decision is not None
assert "{fixture}" in outcome.decision.reason
def test_parse_accepts_null_requested_action_and_int_confidence() -> None:
outcome = _parse(_payload(requested_action=None, confidence=1))
assert outcome.valid is True
assert outcome.decision is not None
assert outcome.decision.requested_action is None
assert outcome.decision.confidence == 1.0
def test_parse_valid_response_with_human_required_true() -> None:
outcome = _parse(_payload(human_required=True))
assert outcome.valid is True
assert outcome.human_required is True
assert outcome.reject_reason is None
@pytest.mark.parametrize(
"raw",
["", "no braces at all", '{"incident_id": "inc-42"', "closing only }"],
)
def test_parse_rejects_missing_json_object(raw: str) -> None:
outcome = _parse(raw=raw)
assert outcome.valid is False
assert outcome.decision is None
assert outcome.human_required is True
assert outcome.reject_reason == "no_json_object_found"
def test_parse_rejects_invalid_json() -> None:
outcome = _parse(raw="{bad json}")
assert outcome.valid is False
assert outcome.human_required is True
assert outcome.reject_reason is not None
assert outcome.reject_reason.startswith("invalid_json:")
def test_parse_rejects_missing_and_extra_keys() -> None:
missing = _payload()
missing.pop("reason")
outcome = _parse(missing)
assert outcome.valid is False
assert outcome.reject_reason == "missing_keys: reason"
outcome = _parse(_payload(notes="extra"))
assert outcome.valid is False
assert outcome.reject_reason == "unexpected_keys: notes"
@pytest.mark.parametrize("field", ["incident_id", "classification", "first_failed_gate", "reason"])
def test_parse_rejects_non_string_fields(field: str) -> None:
outcome = _parse(_payload(**{field: 7}))
assert outcome.valid is False
assert outcome.reject_reason == f"field_type_invalid: {field} must be a string"
def test_parse_rejects_non_boolean_human_required_and_non_list_inferences() -> None:
outcome = _parse(_payload(human_required="yes"))
assert outcome.reject_reason == "field_type_invalid: human_required must be a boolean"
outcome = _parse(_payload(inferences={"not": "a list"}))
assert outcome.reject_reason == "field_type_invalid: inferences must be a list"
@pytest.mark.parametrize("confidence", ["high", None, True])
def test_parse_rejects_non_numeric_confidence(confidence) -> None: # type: ignore[no-untyped-def]
outcome = _parse(_payload(confidence=confidence))
assert outcome.valid is False
assert outcome.reject_reason == "field_type_invalid: confidence must be a number"
@pytest.mark.parametrize("confidence", [1.5, -0.2])
def test_parse_rejects_out_of_range_confidence(confidence: float) -> None:
outcome = _parse(_payload(confidence=confidence))
assert outcome.valid is False
assert outcome.reject_reason == f"confidence_out_of_range: {confidence}"
def test_parse_rejects_bad_facts() -> None:
outcome = _parse(_payload(facts={"not": "a list"}))
assert outcome.reject_reason == "field_type_invalid: facts must be a list"
outcome = _parse(_payload(facts=["not a dict"]))
assert outcome.reject_reason == "fact_invalid: facts[0] must be an object"
outcome = _parse(_payload(facts=[{"statement": "s", "source": "jenkins"}]))
assert outcome.reject_reason == "fact_invalid: facts[0] must have exactly statement, source, reference"
fact = {"statement": "s", "source": "jenkins", "reference": "r", "extra": "x"}
outcome = _parse(_payload(facts=[fact]))
assert outcome.reject_reason == "fact_invalid: facts[0] must have exactly statement, source, reference"
outcome = _parse(_payload(facts=[{"statement": 5, "source": "jenkins", "reference": "r"}]))
assert outcome.reject_reason == "fact_invalid: facts[0] fields must be strings"
def test_parse_rejects_unlisted_fact_source() -> None:
good = _payload()["facts"][0]
bad = dict(good, source="bing")
outcome = _parse(_payload(facts=[good, bad]))
assert outcome.valid is False
assert outcome.reject_reason == "fact_source_invalid: facts[1] source 'bing'"
def test_parse_rejects_malformed_requested_action() -> None:
outcome = _parse(_payload(requested_action=["run_ariadne_job"]))
assert outcome.reject_reason == "requested_action_invalid: must be an object or null"
outcome = _parse(_payload(requested_action={"type": "run_ariadne_job"}))
assert outcome.reject_reason == "requested_action_invalid: must have exactly type and id"
outcome = _parse(_payload(requested_action={"type": "run_ariadne_job", "id": "x", "why": "extra"}))
assert outcome.reject_reason == "requested_action_invalid: must have exactly type and id"
outcome = _parse(_payload(requested_action={"type": "delete_pod", "id": "x"}))
assert outcome.reject_reason == "requested_action_invalid: unsupported type 'delete_pod'"
outcome = _parse(_payload(requested_action={"type": "run_ariadne_job", "id": " "}))
assert outcome.reject_reason == "requested_action_invalid: id must be a non-empty string"
outcome = _parse(_payload(requested_action={"type": "run_ariadne_job", "id": 7}))
assert outcome.reject_reason == "requested_action_invalid: id must be a non-empty string"
def test_parse_rejects_incident_id_mismatch() -> None:
outcome = _parse(incident="inc-99")
assert outcome.valid is False
assert outcome.human_required is True
assert outcome.reject_reason == "incident_id_mismatch: got 'inc-42' expected 'inc-99'"
def test_authorize_all_gates_pass() -> None:
assert _authorize() == (True, "authorized")
def test_authorize_rejects_invalid_outcome() -> None:
allowed, reason = _authorize(outcome=_parse(raw="not json"))
assert allowed is False
assert reason == "response_invalid: no_json_object_found"
def test_authorize_rejects_human_required_decision() -> None:
allowed, reason = _authorize(outcome=_parse(_payload(human_required=True)))
assert allowed is False
assert reason == "human_required"
def test_authorize_requires_terminal_failed_build() -> None:
assert _authorize(build_is_terminal_failure=False) == (False, "build_not_terminal_failure")
def test_authorize_requires_allowlisted_job() -> None:
assert _authorize(job_allowlisted=False) == (False, "job_not_allowlisted")
def test_authorize_requires_expected_classification() -> None:
allowed, reason = _authorize(outcome=_parse(_payload(classification="novel_failure")))
assert allowed is False
assert reason == "classification_mismatch: got 'novel_failure' expected 'known_demo_fixture_failure'"
def test_authorize_requires_requested_action() -> None:
allowed, reason = _authorize(outcome=_parse(_payload(requested_action=None)))
assert allowed is False
assert reason == "requested_action_missing"
def test_authorize_rejects_wrong_action_type_on_handcrafted_decision() -> None:
base = _parse().decision
assert base is not None
decision = module.TriageDecision(
incident_id=base.incident_id,
classification=base.classification,
confidence=base.confidence,
facts=base.facts,
inferences=base.inferences,
first_failed_gate=base.first_failed_gate,
requested_action=module.RequestedAction(type="delete_everything", id="demo-fixture-reset"),
human_required=False,
reason=base.reason,
)
outcome = module.DecisionOutcome(valid=True, decision=decision, human_required=False, reject_reason=None)
assert _authorize(outcome=outcome) == (False, "requested_action_type_invalid")
def test_authorize_requires_action_in_allowlist() -> None:
allowed, reason = _authorize(cfg=_cfg(allowed_actions=["other-job"]))
assert allowed is False
assert reason == "action_not_allowlisted: 'demo-fixture-reset'"
def test_authorize_requires_minimum_confidence() -> None:
allowed, reason = _authorize(outcome=_parse(_payload(confidence=0.5)))
assert allowed is False
assert reason == "confidence_below_minimum: 0.5 < 0.8"
def test_authorize_requires_evidence_signature() -> None:
assert _authorize(evidence_has_signature=False) == (False, "evidence_signature_missing")
def test_authorize_enforces_action_budget() -> None:
assert _authorize(prior_action_count=1) == (False, "max_actions_reached")
assert _authorize(prior_action_count=0, cfg=_cfg(max_actions_per_incident=2)) == (True, "authorized")
def test_authorize_requires_autoremediation_enabled() -> None:
allowed, reason = _authorize(cfg=_cfg(autoremediation_enabled=False))
assert allowed is False
assert reason == "autoremediation_disabled"
def test_authorize_names_first_failing_gate_in_order() -> None:
allowed, reason = _authorize(
outcome=_parse(_payload(human_required=True)),
cfg=_cfg(autoremediation_enabled=False),
build_is_terminal_failure=False,
evidence_has_signature=False,
)
assert allowed is False
assert reason == "human_required"
def test_authorize_with_empty_cfg_defaults_closed() -> None:
allowed, reason = _authorize(cfg={})
assert allowed is False
assert reason == "action_not_allowlisted: 'demo-fixture-reset'"
def test_authorize_falls_back_to_conservative_cfg_values() -> None:
cfg = _cfg(min_confidence="high", max_actions_per_incident="many")
allowed, reason = _authorize(cfg=cfg)
assert allowed is False
assert reason == "confidence_below_minimum: 0.93 < 1.0"
def _mapping_cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"allowed_actions": ["repair_demo_fixture", "retry_transient_infra"],
"action_classifications": {
"known_demo_fixture_failure": "repair_demo_fixture",
"transient_infra_failure": "retry_transient_infra",
},
}
base.update(overrides)
return _cfg(**base)
def _mapped_payload(classification: str, action_id: str) -> dict:
return _payload(
classification=classification,
requested_action={"type": "run_ariadne_job", "id": action_id},
)
def test_authorize_accepts_each_registered_classification() -> None:
for classification, action_id in _mapping_cfg()["action_classifications"].items():
outcome = _parse(_mapped_payload(classification, action_id))
assert _authorize(outcome=outcome, cfg=_mapping_cfg()) == (True, "authorized")
def test_authorize_rejects_unregistered_classification() -> None:
outcome = _parse(_mapped_payload("novel_failure", "retry_transient_infra"))
allowed, reason = _authorize(outcome=outcome, cfg=_mapping_cfg())
assert allowed is False
assert reason == "classification_not_supported: 'novel_failure'"
def test_authorize_rejects_action_mapped_to_another_classification() -> None:
outcome = _parse(_mapped_payload("transient_infra_failure", "repair_demo_fixture"))
allowed, reason = _authorize(outcome=outcome, cfg=_mapping_cfg())
assert allowed is False
assert reason == (
"action_does_not_match_classification: 'repair_demo_fixture' expected 'retry_transient_infra'"
)
def test_authorize_still_requires_the_mapped_action_to_be_allowlisted() -> None:
outcome = _parse(_mapped_payload("transient_infra_failure", "retry_transient_infra"))
cfg = _mapping_cfg(allowed_actions=["repair_demo_fixture"])
allowed, reason = _authorize(outcome=outcome, cfg=cfg)
assert allowed is False
assert reason == "action_not_allowlisted: 'retry_transient_infra'"
def test_authorize_reports_missing_action_before_the_mapping_gate() -> None:
outcome = _parse(_payload(classification="transient_infra_failure", requested_action=None))
assert _authorize(outcome=outcome, cfg=_mapping_cfg()) == (False, "requested_action_missing")
def test_authorize_mapping_still_enforces_the_remaining_gates() -> None:
outcome = _parse(_mapped_payload("transient_infra_failure", "retry_transient_infra"))
assert _authorize(outcome=outcome, cfg=_mapping_cfg(), evidence_has_signature=False) == (
False,
"evidence_signature_missing",
)
assert _authorize(outcome=outcome, cfg=_mapping_cfg(), prior_action_count=1) == (
False,
"max_actions_reached",
)
assert _authorize(outcome=outcome, cfg=_mapping_cfg(autoremediation_enabled=False)) == (
False,
"autoremediation_disabled",
)
def test_authorize_ignores_an_empty_or_malformed_mapping() -> None:
for mapping in ({}, None, "known_demo_fixture_failure=repair_demo_fixture", []):
cfg = _cfg(action_classifications=mapping)
assert _authorize(cfg=cfg) == (True, "authorized")
allowed, reason = _authorize(outcome=_parse(_payload(classification="other")), cfg=cfg)
assert allowed is False
assert reason == "classification_mismatch: got 'other' expected 'known_demo_fixture_failure'"