All checks were successful
Tests / Declarative: Post Actions passed: 1404
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>
256 lines
8.7 KiB
Python
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
|