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
238 lines
8.9 KiB
Python
238 lines
8.9 KiB
Python
"""Tests for :mod:`integrations.hermes.agent`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from integrations.hermes.agent import HermesAgent
|
|
from integrations.hermes.classifier import IncidentClassifier
|
|
from integrations.hermes.incident import HermesIncident, IncidentSeverity
|
|
|
|
|
|
class TestAgentProcess:
|
|
def test_process_runs_classifier_over_explicit_lines(self) -> None:
|
|
emitted: list[HermesIncident] = []
|
|
agent = HermesAgent(
|
|
sink=emitted.append,
|
|
log_path="/dev/null",
|
|
classifier=IncidentClassifier(warning_burst_threshold=3, warning_burst_window_s=60.0),
|
|
)
|
|
|
|
lines = [
|
|
"2026-05-12 00:00:00,000 WARNING gateway.platforms.telegram: conflict 1",
|
|
"2026-05-12 00:00:10,000 WARNING gateway.platforms.telegram: conflict 2",
|
|
"2026-05-12 00:00:20,000 WARNING gateway.platforms.telegram: conflict 3",
|
|
]
|
|
out = agent.process(lines)
|
|
|
|
assert len(out) == 1
|
|
assert out[0].rule == "warning_burst"
|
|
assert out[0].severity is IncidentSeverity.MEDIUM
|
|
# Sink received the same incident the explicit list did.
|
|
assert emitted == out
|
|
|
|
def test_sink_exception_does_not_break_pipeline(self) -> None:
|
|
calls: list[HermesIncident] = []
|
|
|
|
def flaky_sink(incident: HermesIncident) -> None:
|
|
calls.append(incident)
|
|
if len(calls) == 1:
|
|
raise RuntimeError("first dispatch fails")
|
|
|
|
agent = HermesAgent(
|
|
sink=flaky_sink,
|
|
log_path="/dev/null",
|
|
classifier=IncidentClassifier(warning_burst_threshold=2, warning_burst_window_s=60.0),
|
|
)
|
|
|
|
# Two ERROR records each emit error_severity; the first sink call
|
|
# raises, the second still has to land — otherwise a buggy sink
|
|
# would silently disable detection.
|
|
agent.process(
|
|
[
|
|
"2026-05-12 00:00:00,000 ERROR root: boom 1",
|
|
"2026-05-12 00:00:01,000 ERROR root: boom 2",
|
|
]
|
|
)
|
|
|
|
assert len(calls) == 2
|
|
|
|
def test_process_flushes_trailing_traceback(self) -> None:
|
|
"""One-shot ``process()`` must mirror ``HermesLogsTool`` and flush the
|
|
classifier so an open traceback at end-of-input becomes an incident.
|
|
"""
|
|
emitted: list[HermesIncident] = []
|
|
agent = HermesAgent(
|
|
sink=emitted.append,
|
|
log_path="/dev/null",
|
|
classifier=IncidentClassifier(),
|
|
)
|
|
lines = [
|
|
"2026-05-12 00:00:00,000 ERROR tools.x: Traceback (most recent call last):",
|
|
' File "/x", line 1, in foo',
|
|
]
|
|
out = agent.process(lines)
|
|
|
|
assert any(i.rule == "traceback" for i in out)
|
|
assert emitted == out
|
|
|
|
|
|
class TestAgentLifecycle:
|
|
def test_start_stop_processes_appended_lines(self, tmp_path: Path) -> None:
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("", encoding="utf-8")
|
|
|
|
emitted: list[HermesIncident] = []
|
|
seen = threading.Event()
|
|
|
|
def sink(incident: HermesIncident) -> None:
|
|
emitted.append(incident)
|
|
seen.set()
|
|
|
|
# ``from_start=True`` avoids a race where an append lands before the first
|
|
# poll: default live-tail would seek to EOF and skip that line (``tail -f``).
|
|
agent = HermesAgent(
|
|
sink=sink,
|
|
log_path=log,
|
|
poll_interval_s=0.01,
|
|
from_start=True,
|
|
classifier=IncidentClassifier(warning_burst_threshold=2, warning_burst_window_s=60.0),
|
|
)
|
|
agent.start()
|
|
try:
|
|
with log.open("a", encoding="utf-8") as fh:
|
|
fh.write("2026-05-12 00:00:00,000 ERROR root: live failure\n")
|
|
fh.flush()
|
|
assert seen.wait(timeout=5.0), "agent did not surface a live ERROR record"
|
|
finally:
|
|
agent.stop()
|
|
|
|
assert any(i.rule == "error_severity" for i in emitted)
|
|
assert agent.is_running is False
|
|
|
|
def test_context_manager_starts_and_stops(self, tmp_path: Path) -> None:
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("", encoding="utf-8")
|
|
|
|
with HermesAgent(sink=lambda _i: None, log_path=log, poll_interval_s=0.01) as agent:
|
|
time.sleep(0.05)
|
|
assert agent.is_running is True
|
|
|
|
assert agent.is_running is False
|
|
|
|
def test_start_is_idempotent(self, tmp_path: Path) -> None:
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("", encoding="utf-8")
|
|
agent = HermesAgent(sink=lambda _i: None, log_path=log, poll_interval_s=0.01)
|
|
agent.start()
|
|
try:
|
|
first_thread = agent._thread # type: ignore[attr-defined]
|
|
agent.start()
|
|
assert agent._thread is first_thread # type: ignore[attr-defined]
|
|
finally:
|
|
agent.stop()
|
|
|
|
def test_stop_flushes_buffered_traceback(self, tmp_path: Path) -> None:
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("", encoding="utf-8")
|
|
|
|
emitted: list[HermesIncident] = []
|
|
traceback_seen = threading.Event()
|
|
|
|
def sink(incident: HermesIncident) -> None:
|
|
emitted.append(incident)
|
|
if incident.rule == "traceback":
|
|
traceback_seen.set()
|
|
|
|
agent = HermesAgent(sink=sink, log_path=log, poll_interval_s=0.01, from_start=True)
|
|
agent.start()
|
|
try:
|
|
with log.open("a", encoding="utf-8") as fh:
|
|
fh.write(
|
|
"2026-05-12 00:00:00,000 ERROR tools.x: Traceback (most recent call last):\n"
|
|
)
|
|
fh.write(' File "/x", line 1, in foo\n')
|
|
fh.flush()
|
|
time.sleep(0.2)
|
|
finally:
|
|
agent.stop()
|
|
|
|
assert traceback_seen.is_set(), "expected traceback incident to be flushed on stop()"
|
|
|
|
def test_stop_waits_for_slow_sink_after_initial_join_timeout(self, tmp_path: Path) -> None:
|
|
"""stop(timeout) must not drop the thread reference while the poller
|
|
is still inside ``_dispatch`` — :meth:`start` would otherwise clear
|
|
the stop event and allow two poll loops to share state."""
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("", encoding="utf-8")
|
|
sink_released = threading.Event()
|
|
sink_invoked = threading.Event()
|
|
stop_completed = threading.Event()
|
|
|
|
def sink(_incident: HermesIncident) -> None:
|
|
sink_invoked.set()
|
|
sink_released.wait(timeout=30.0)
|
|
|
|
agent = HermesAgent(
|
|
sink=sink,
|
|
log_path=log,
|
|
poll_interval_s=0.01,
|
|
from_start=True,
|
|
classifier=IncidentClassifier(warning_burst_threshold=2, warning_burst_window_s=60.0),
|
|
)
|
|
agent.start()
|
|
try:
|
|
with log.open("a", encoding="utf-8") as fh:
|
|
fh.write("2026-05-12 00:00:00,000 ERROR root: blocks shutdown\n")
|
|
fh.flush()
|
|
assert sink_invoked.wait(timeout=5.0), (
|
|
"sink not invoked — poller may have missed the line"
|
|
)
|
|
|
|
def stopper() -> None:
|
|
agent.stop(timeout=0.05)
|
|
stop_completed.set()
|
|
|
|
threading.Thread(target=stopper, daemon=True).start()
|
|
assert not stop_completed.wait(timeout=0.5)
|
|
sink_released.set()
|
|
assert stop_completed.wait(timeout=5.0), "stop() should block until dispatch returns"
|
|
assert agent.is_running is False
|
|
finally:
|
|
sink_released.set()
|
|
|
|
def test_stop_does_not_discard_tailer_pending_lines(self, tmp_path: Path) -> None:
|
|
"""_run() must not break out of the tailer loop early on stop_event: the
|
|
tailer already drains _pending_lines before exiting, so an early break in
|
|
the agent loop discards lines the tailer has already buffered."""
|
|
log = tmp_path / "errors.log"
|
|
# Write enough lines that the tailer buffers a full chunk before the
|
|
# agent even starts, so they land in _pending_lines before any poll.
|
|
lines = [f"2026-05-12 00:00:{i:02d},000 ERROR root: line {i}\n" for i in range(15)]
|
|
log.write_text("".join(lines), encoding="utf-8")
|
|
|
|
emitted: list[HermesIncident] = []
|
|
agent = HermesAgent(
|
|
sink=emitted.append,
|
|
log_path=log,
|
|
poll_interval_s=0.01,
|
|
from_start=True,
|
|
classifier=IncidentClassifier(
|
|
warning_burst_threshold=100,
|
|
warning_burst_window_s=300.0,
|
|
),
|
|
)
|
|
agent.start()
|
|
# Give the agent time to read the file and buffer lines.
|
|
time.sleep(0.3)
|
|
agent.stop()
|
|
|
|
# Every distinct ERROR line should have produced an incident; we
|
|
# need at least one to confirm pending lines were not discarded.
|
|
assert len(emitted) >= 1, (
|
|
"agent discarded buffered lines — early stop_event break in _run() "
|
|
"is skipping lines the tailer already queued in _pending_lines"
|
|
)
|