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

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"
)