ariadne/ariadne/services/hermes_autotriage_events.py
codex eebc9b16c4
All checks were successful
Tests / Declarative: Post Actions passed: 1404
feat(hermes): let an escalated diagnosis carry the fix it could not apply
Ariadne opens a pull request when it can: the repository is mapped, the file
is in the write allowlist, and the change is one anchored snippet the
validator can check. When any of that fails the incident escalates with a
diagnosis and nothing else - even though the model that wrote the diagnosis
frequently knows exactly what the fix is. That knowledge was discarded at the
moment it was most useful, because the cases where no patch is possible are
exactly the cases a maintainer has to do by hand.

A diagnosis may now carry up to three code suggestions: the file, what is
wrong there, and the code to change it to. Rendered into the issue under a
heading that says the change was not applied, because a code block in an issue
reads like something that already happened unless it is told otherwise.

Deliberately not a patch, and the difference is the safety story. A patch must
survive the validator because Ariadne acts on it. A suggestion is read by a
person who is already going to edit that file, so being wrong costs them a
moment's thought rather than a bad commit - which is why suggestions may
describe changes too large or too diffuse for the patcher to have attempted,
and why nothing here is anchored, applied or pushed.

Bounded at three. A diagnosis that suggests a dozen changes has stopped
diagnosing and started rewriting, and an issue that long buries the reason a
person was called in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:32:03 -03:00

256 lines
8.7 KiB
Python

"""Event-log reads and writes for the Hermes auto-triage incident timeline.
Incidents, diagnoses, and actions are appended to the shared Ariadne event
log; folding those rows back into the latest state per incident is what makes
the scheduler tick idempotent and what enforces the one-action-per-incident
budget across pod restarts.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any
from . import hermes_code_suggestion as code_suggestion_field
from . import hermes_suggested_remediation as suggestion_field
from .hermes_autotriage_metrics import (
HERMES_TRIAGE_ACTION_TOTAL,
HERMES_TRIAGE_SUGGESTION_TOTAL,
set_incident_gauge,
)
INCIDENT_EVENT_TYPE = "hermes_autotriage_incident"
DIAGNOSIS_EVENT_TYPE = "hermes_autotriage_diagnosis"
ACTION_EVENT_TYPE = "hermes_autotriage_action"
_EVENT_SCAN_LIMIT = 500
_ACTION_COUNTER_RESULTS = {"executed": "success"}
@dataclass(frozen=True)
class Authorization:
"""Represent the authorization verdict recorded on a diagnosis event.
Inputs: the gate-chain verdict, the first failing gate name (or
"authorized"), and the evidence marker that satisfied the signature gate
when the action's signature check names one. Outputs: the fields written
onto the diagnosis event so the audit trail records why an action was
allowed.
"""
allowed: bool
reason: str
evidence_marker: str | None = None
def incident_state(storage: Any) -> dict[str, dict[str, Any]]:
"""Fold incident events into the latest state per incident id.
Inputs: a storage object providing list_events. Outputs: a map of
incident id to its most recent event detail.
"""
rows = storage.list_events(limit=_EVENT_SCAN_LIMIT, event_type=INCIDENT_EVENT_TYPE)
incidents: dict[str, dict[str, Any]] = {}
for row in rows:
detail = event_detail(row)
incident_id = str(detail.get("incident_id") or "") if detail else ""
if incident_id and incident_id not in incidents:
incidents[incident_id] = detail or {}
return incidents
def event_detail(row: Any) -> dict[str, Any] | None:
"""Return an event row's detail as a dict, decoding stored JSON.
Inputs: one event row. Outputs: the detail dict, or None when the row or
its detail is missing, malformed, or not an object.
"""
detail = row.get("detail") if isinstance(row, dict) else None
if isinstance(detail, dict):
return detail
if isinstance(detail, str):
try:
payload = json.loads(detail)
except json.JSONDecodeError:
return None
return payload if isinstance(payload, dict) else None
return None
def prior_action_count(storage: Any, incident_id: str) -> int:
"""Count previously recorded action events for one incident.
Inputs: a storage object providing list_events and an incident id.
Outputs: the number of action events already recorded, which is what
keeps one incident to a single remediation attempt.
"""
rows = storage.list_events(limit=_EVENT_SCAN_LIMIT, event_type=ACTION_EVENT_TYPE)
count = 0
for row in rows:
detail = event_detail(row)
if detail is not None and detail.get("incident_id") == incident_id:
count += 1
return count
def incident_base(incident: dict[str, Any]) -> dict[str, Any]:
"""Normalize a stored incident detail into the base identity fields.
Inputs: a stored incident event detail. Outputs: {"incident_id", "job",
"build_number"} with coerced types.
"""
return {
"incident_id": str(incident.get("incident_id") or ""),
"job": str(incident.get("job") or ""),
"build_number": _int_value(incident.get("build_number")),
}
def record_incident(
storage: Any,
base: dict[str, Any],
status: str,
phase: dict[str, Any] | None = None,
extra_statuses: tuple[str, ...] = (),
) -> None:
"""Append an incident event and publish its one-hot status gauge.
Inputs: storage, the incident identity fields, the new status, optional
phase detail, and any extra statuses that should also read 1 on the
gauge. Outputs: none.
"""
storage.record_event(INCIDENT_EVENT_TYPE, {**base, "status": status, "phase": phase or {}})
set_incident_gauge(str(base["job"]), str(base["build_number"]), {status, *extra_statuses})
def record_action(
storage: Any,
base: dict[str, Any],
action_id: str,
result: str,
detail: dict[str, Any] | None,
) -> None:
"""Append an action event and increment the bounded action counter.
Inputs: storage, the incident identity fields, the allowlisted action id,
the lifecycle result (requested/accepted/executed/failed), and optional
detail. Outputs: none.
"""
HERMES_TRIAGE_ACTION_TOTAL.labels(
action=action_id, result=_ACTION_COUNTER_RESULTS.get(result, result)
).inc()
payload: dict[str, Any] = {**base, "action": action_id, "result": result}
if detail:
payload["detail"] = detail
storage.record_event(ACTION_EVENT_TYPE, payload)
def record_diagnosis(
storage: Any,
base: dict[str, Any],
run: Any,
outcome: Any,
authorization: Authorization,
) -> None:
"""Append a diagnosis event with run metadata and the parsed outcome.
Inputs: storage, the incident identity fields, the Hermes run result, the
parsed DecisionOutcome (or None when the run never completed), and the
Authorization verdict. Outputs: none.
Counts a proposed remediation here rather than at render time: this runs
exactly once per diagnosis, so the counter measures how often the allowlist
fell short rather than how often an issue body was formatted.
"""
decision = getattr(outcome, "decision", None)
if getattr(decision, "suggested_remediation", None) is not None:
HERMES_TRIAGE_SUGGESTION_TOTAL.inc()
storage.record_event(
DIAGNOSIS_EVENT_TYPE,
{
**base,
"run": {
"status": run.status,
"run_id": run.run_id,
"session_id": run.session_id,
"error": run.error,
"duration_seconds": run.duration_seconds,
"denied_approvals": run.denied_approvals,
},
"outcome": outcome_phase(outcome) if outcome is not None else None,
"authorized": authorization.allowed,
"authorize_reason": authorization.reason,
"evidence_marker": authorization.evidence_marker,
},
)
def outcome_phase(outcome: Any) -> dict[str, Any]:
"""Summarize a DecisionOutcome for event details.
Inputs: a DecisionOutcome. Outputs: the bounded subset of decision fields
recorded on incident and diagnosis events.
"""
decision = outcome.decision
if decision is None:
return {"valid": False, "reject_reason": outcome.reject_reason}
return {
"valid": outcome.valid,
"classification": decision.classification,
"confidence": decision.confidence,
"first_failed_gate": decision.first_failed_gate,
"human_required": decision.human_required,
"requested_action": None if decision.requested_action is None else decision.requested_action.id,
"suggested_remediation": suggestion_field.as_detail(
getattr(decision, "suggested_remediation", None)
),
"code_suggestions": code_suggestion_field.as_detail(
getattr(decision, "code_suggestions", None)
),
}
def _int_value(value: Any) -> int:
"""Coerce a value to int, defaulting to zero."""
try:
return int(value)
except (TypeError, ValueError):
return 0
def recorded_classification(storage: Any, incident_id: str) -> str | None:
"""Return the classification a past diagnosis recorded for an incident.
Inputs: the event storage and an incident id. Outputs: the classification
string, or None when no diagnosis event carries one.
Issue dedupe matches on job and classification, so a later filing must
reuse the label the original diagnosis produced. Filing under a different
one would not match the open issue and would duplicate it, which is
precisely what the dedupe exists to prevent - hence None rather than a
guess when nothing was recorded.
"""
rows = storage.list_events(limit=_EVENT_SCAN_LIMIT, event_type=DIAGNOSIS_EVENT_TYPE)
for row in rows:
detail = event_detail(row) or {}
if str(detail.get("incident_id") or "") != incident_id:
continue
outcome = detail.get("outcome")
if isinstance(outcome, dict):
classification = str(outcome.get("classification") or "").strip()
if classification:
return classification
return None