Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

209 lines
7.9 KiB
Python

"""Hermes agent: glue between the file tailer, parser, classifier, and sinks.
Public surface is :class:`HermesAgent`. Construct one with the path to a
Hermes log file (defaults to ``~/.hermes/logs/errors.log``) and a callable
that handles each detected :class:`HermesIncident`. Call :meth:`start` to
spawn the polling thread, :meth:`stop` to shut it down, or use the agent
as a context manager for guaranteed cleanup.
The agent is *I/O bounded*: a single daemon thread polls the log file and
synchronously runs the parser/classifier/sink pipeline. This is fine for
log files written at human-scale rates (Hermes' ``errors.log`` is in the
single-digit lines/second at peak); higher-rate files should consider a
queue between the tailer and the classifier in a follow-up.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable, Iterable
from pathlib import Path
from types import TracebackType
from typing import Final
from integrations.hermes.classifier import IncidentClassifier
from integrations.hermes.incident import HermesIncident, LogLevel
from integrations.hermes.parser import parse_log_line
from integrations.hermes.tailer import DEFAULT_POLL_INTERVAL_S, FileTailer
logger = logging.getLogger(__name__)
IncidentSink = Callable[[HermesIncident], None]
DEFAULT_LOG_PATH: Final[Path] = Path.home() / ".hermes" / "logs" / "errors.log"
_THREAD_JOIN_TIMEOUT_S: Final[float] = 2.0
class HermesAgent:
"""Polls a Hermes log file and emits structured incidents.
Parameters
----------
sink:
Callable invoked for each detected incident. Exceptions raised by
the sink are logged but do not stop the polling loop — a buggy
sink must not silently disable incident detection.
log_path:
Path to the Hermes log file. Defaults to
``~/.hermes/logs/errors.log``.
classifier:
Optional pre-configured :class:`IncidentClassifier`. Construct one
explicitly when you need non-default thresholds; otherwise the
agent creates a classifier with the module defaults.
poll_interval_s, from_start:
Forwarded to :class:`FileTailer`. ``from_start=True`` replays the
existing file contents before live tailing, which is useful for
one-shot scans and tests.
"""
def __init__(
self,
*,
sink: IncidentSink,
log_path: Path | str = DEFAULT_LOG_PATH,
classifier: IncidentClassifier | None = None,
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
from_start: bool = False,
) -> None:
self._sink = sink
self._log_path = Path(log_path)
self._classifier = classifier if classifier is not None else IncidentClassifier()
self._stop_event = threading.Event()
self._tailer = FileTailer(
self._log_path,
poll_interval_s=poll_interval_s,
from_start=from_start,
stop_event=self._stop_event,
)
self._thread: threading.Thread | None = None
self._prev_level: LogLevel | None = None
# Serializes parser + _prev_level + classifier.observe between the
# polling thread and synchronous :meth:`process` calls.
self._pipeline_lock = threading.Lock()
@property
def log_path(self) -> Path:
return self._log_path
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
"""Spawn the polling thread. Idempotent if already running."""
if self.is_running:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run,
name="hermes-agent",
daemon=True,
)
self._thread.start()
def stop(self, *, timeout: float = _THREAD_JOIN_TIMEOUT_S) -> None:
"""Signal the polling thread and wait until it has exited.
The poller is joined for up to ``timeout`` seconds first so slow
shutdown paths can be noticed in logs; if it is still alive after
that, we block without a further timeout until the thread finishes.
Only then may :meth:`start` clear the stop event safely — otherwise
a prior poller could resume after a "stopped" agent appeared idle.
"""
self._stop_event.set()
thread = self._thread
if thread is not None:
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning(
"hermes-agent: polling thread still running after %.1fs; "
"waiting for clean shutdown",
timeout,
)
thread.join()
self._thread = None
# Surface any tracebacks that were buffered by the classifier so
# an incident never gets stuck in memory after shutdown.
with self._pipeline_lock:
pending = self._classifier.flush()
for incident in pending:
self._dispatch(incident)
def process(self, lines: Iterable[str]) -> list[HermesIncident]:
"""Run the parser/classifier pipeline over an explicit line sequence.
Useful for one-shot scans (``opensre hermes scan``) and unit tests
without the polling thread.
"""
emitted: list[HermesIncident] = []
for line in lines:
with self._pipeline_lock:
record = parse_log_line(line, prev_level=self._prev_level)
if record is None:
continue
self._prev_level = record.level if not record.is_continuation else self._prev_level
incidents = list(self._classifier.observe(record))
for incident in incidents:
emitted.append(incident)
self._dispatch(incident)
with self._pipeline_lock:
flushed = list(self._classifier.flush())
for incident in flushed:
emitted.append(incident)
self._dispatch(incident)
return emitted
def __enter__(self) -> HermesAgent:
self.start()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.stop()
def _run(self) -> None:
try:
for line in self._tailer:
# Do NOT break here on stop_event: FileTailer.__iter__ already
# drains _pending_lines before exiting when the stop event fires.
# An early break here would discard lines that the tailer has
# already buffered, silently defeating the pending-line drain
# that was added to prevent exactly that data loss.
with self._pipeline_lock:
record = parse_log_line(line, prev_level=self._prev_level)
if record is None:
continue
if not record.is_continuation:
self._prev_level = record.level
incidents = list(self._classifier.observe(record))
for incident in incidents:
self._dispatch(incident)
except Exception:
# The polling thread is the agent's only worker; if we let an
# exception propagate we lose all future incidents silently.
# Log it loudly but keep the process alive — the operator can
# restart the agent after fixing the underlying cause.
logger.exception("hermes-agent polling thread crashed")
def _dispatch(self, incident: HermesIncident) -> None:
try:
self._sink(incident)
except Exception:
logger.exception(
"hermes incident sink raised: rule=%s logger=%s",
incident.rule,
incident.logger,
)
__all__ = [
"DEFAULT_LOG_PATH",
"HermesAgent",
"IncidentSink",
]