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

473 lines
20 KiB
Python

"""Efficient, cursor-based polling primitive for Hermes log files.
This module is the **shared engine** behind both the agent-facing
``HermesLogsTool`` (``integrations.hermes.tools.hermes_logs_tool``) and the test helper
(``tests.utils.hermes_logs_helper``). Centralising the cursor logic
here means the production tool and the test suite never drift.
Design goals
------------
* **O(new-lines) per poll** — never re-read the entire log on each
call. A :class:`HermesLogCursor` records the file's identity (path,
device, inode) and last byte offset; subsequent polls seek directly
to the offset and read only what is new.
* **Rotation- and truncation-safe** — every poll re-stat's the file
and resets the offset if the inode changed (rotation) or the size
shrank (truncation), so an active poller never silently misses
lines after ``logrotate`` runs.
* **No daemon thread required** — the agent calls this from its main
loop and the test helper from a single test thread. For the
background-polling case the existing ``HermesAgent`` already wraps
``FileTailer``; this module is the synchronous primitive both rely
on.
* **Bounded** — ``max_lines`` caps a single poll so a multi-GB rotated
file can't blow up the caller's memory.
"""
from __future__ import annotations
import os
import re
from collections import deque
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Final
from integrations.hermes.classifier import IncidentClassifier
from integrations.hermes.incident import HermesIncident, LogLevel, LogRecord
from integrations.hermes.parser import parse_log_line
# Hard upper bound on a single poll's byte read. Hermes errors.log is
# usually <50 MB even on busy installs; 64 MB is a generous ceiling
# that prevents pathological reads if a caller passes max_lines=None.
_DEFAULT_MAX_BYTES: Final[int] = 64 * 1024 * 1024
def _opens_python_traceback(message: str) -> bool:
"""True when *message* starts the standard logging exception header.
Only such lines are queued for ``since`` inheritance: every other
non-continuation line updates ``last_parent_passes_since`` but must not
occupy a FIFO slot, otherwise a filtered pre-``since`` noise line sits
ahead of a passing Traceback header and the first frame pops the wrong
decision.
"""
lower = message.casefold()
return "traceback" in lower and "most recent call" in lower
@dataclass(frozen=True, slots=True)
class HermesLogCursor:
"""Resumable read position in a Hermes log file.
The triple ``(path, device, inode)`` identifies the *physical* file
so a log rotation that replaces ``errors.log`` with a fresh file
invalidates the cursor and the poller starts from offset 0.
``offset`` is the byte position immediately AFTER the last line
yielded on the previous poll. A poller seeks to this offset, reads,
and returns a new cursor with an updated offset.
Cursors are cheap to round-trip through JSON — the agent tool emits
one in every response so the LLM can pass it back on the next call
to "tail since last time".
"""
path: str
device: int
inode: int
offset: int
@classmethod
def at_start(cls, path: Path | str) -> HermesLogCursor:
"""Cursor pointing at the very first byte of ``path``.
Identity fields (device/inode) are zeroed so the first real
poll will treat any existing file as 'new' and re-stat it.
"""
return cls(path=str(path), device=0, inode=0, offset=0)
@classmethod
def at_end(cls, path: Path | str) -> HermesLogCursor:
"""Cursor pointing at the current end-of-file for ``path``.
Used by ``opensre hermes watch`` to start a live tail without
replaying historical lines. ``stat`` failures return an
at-start cursor so the next poll can recover gracefully.
"""
p = Path(path)
try:
stat = p.stat()
except (FileNotFoundError, PermissionError):
return cls.at_start(p)
return cls(path=str(p), device=stat.st_dev, inode=stat.st_ino, offset=stat.st_size)
def to_token(self) -> str:
"""Compact opaque token (safe for JSON / LLM round-trip)."""
return f"{self.device}:{self.inode}:{self.offset}@{self.path}"
@classmethod
def from_token(cls, token: str) -> HermesLogCursor:
"""Inverse of :meth:`to_token`. Raises ``ValueError`` on bad input.
We accept the exact shape we emit; refusing anything else
prevents a malformed LLM-supplied cursor from silently
defaulting to ``at_start`` and replaying gigabytes of logs.
Callers that re-ingest tokens from untrusted context (e.g. an
LLM echoing text from a log line) must also call
:meth:`validate_expected_log_path` before opening ``path``.
"""
match = re.fullmatch(r"(\d+):(\d+):(\d+)@(.+)", token)
if match is None:
raise ValueError(f"unrecognised HermesLogCursor token: {token!r}")
return cls(
device=int(match.group(1)),
inode=int(match.group(2)),
offset=int(match.group(3)),
path=match.group(4),
)
def validate_expected_log_path(self, expected: Path | str) -> None:
"""Ensure ``self.path`` is the same file as ``expected``.
The token embeds a raw path string; without this check, a
crafted token could point at an arbitrary filesystem path while
the tool operator believes they are tailing the configured
Hermes log.
"""
try:
token_resolved = Path(self.path).expanduser().resolve(strict=False)
want_resolved = Path(expected).expanduser().resolve(strict=False)
except (OSError, ValueError, RuntimeError) as exc:
raise ValueError("cannot resolve cursor path or requested log path") from exc
if token_resolved != want_resolved:
raise ValueError("cursor token does not refer to the requested log file")
@dataclass(frozen=True, slots=True)
class HermesLogPoll:
"""Result of a single :func:`poll_hermes_logs` invocation."""
cursor: HermesLogCursor
records: tuple[LogRecord, ...]
incidents: tuple[HermesIncident, ...]
# True when the underlying file changed identity (rotation) or
# shrank (truncation) since the previous cursor was captured. The
# poller transparently rewinds in either case; this flag is purely
# informational for callers that want to log the transition.
rotation_detected: bool = False
# Number of lines NOT returned because the read hit ``max_lines`` or
# the per-poll byte budget stopped before EOF (cursor still before
# ``stat.st_size``). Callers should re-poll with the returned cursor.
# ``0`` means everything through EOF was consumed under both caps.
truncated_lines: int = 0
# Stats useful to the agent / tests without re-scanning the
# returned records: how many lines were parsed vs. yielded.
parsed_line_count: int = field(default=0)
@property
def has_new_data(self) -> bool:
return bool(self.records) or bool(self.incidents) or self.rotation_detected
def poll_hermes_logs(
log_path: Path | str,
cursor: HermesLogCursor | None = None,
*,
max_lines: int | None = 2000,
classifier: IncidentClassifier | None = None,
level_filter: frozenset[LogLevel] | None = None,
since: datetime | None = None,
) -> HermesLogPoll:
"""Read new lines from a Hermes log file since ``cursor``.
Parameters
----------
log_path:
Path to the Hermes log file (typically
``~/.hermes/logs/errors.log``).
cursor:
Resume position. ``None`` means "start from offset 0" and is
equivalent to passing ``HermesLogCursor.at_start(log_path)``.
max_lines:
Cap on records returned per poll. ``None`` disables the cap.
When the cap is hit, ``truncated_lines`` reports how many
records were left behind; the returned cursor still advances
past the records that were yielded so a follow-up poll picks
up where we left off.
classifier:
Optional :class:`IncidentClassifier`. When provided, every
parsed record is fed through it and the emitted incidents are
included in :class:`HermesLogPoll`. Passing the same
classifier instance across polls preserves traceback buffering
/ warning-burst windows across calls — that's the contract the
agent and the test helper rely on.
level_filter:
Optional set of :class:`LogLevel` values to retain. When set,
records of other levels are still **observed by the classifier**
(so traceback continuations and warning bursts still work) but
are dropped from the returned ``records`` tuple. Defaults to no
filter.
since:
Drop records with ``timestamp < since`` from the returned
records. As with ``level_filter``, the classifier still
observes them so cross-poll burst windows remain intact.
Returns
-------
:class:`HermesLogPoll` with the new records, any incidents the
classifier emitted, an updated cursor, and rotation/truncation
flags.
Failure modes
-------------
* **Missing file:** returns an empty :class:`HermesLogPoll` with
an :meth:`HermesLogCursor.at_start` cursor — a follow-up poll
after the file is created will read from offset 0.
* **Permission error:** raised — the caller is expected to surface
this through their normal error path (the tool serialises it
into a ``{"error": ...}`` response).
"""
p = Path(log_path)
resolved_cursor = cursor or HermesLogCursor.at_start(p)
classifier_local = classifier if classifier is not None else IncidentClassifier()
try:
stat = p.stat()
except FileNotFoundError:
return HermesLogPoll(
cursor=HermesLogCursor.at_start(p),
records=(),
incidents=(),
rotation_detected=False,
truncated_lines=0,
parsed_line_count=0,
)
rotation_detected = _is_rotation_or_truncation(resolved_cursor, stat)
start_offset = 0 if rotation_detected else min(resolved_cursor.offset, stat.st_size)
# If nothing new since last poll, short-circuit before opening the
# file. This is the hot path on idle systems: an agent polling
# every few seconds against a quiet errors.log should be ~free.
#
# We still update the cursor's device/inode from the current stat
# so a subsequent rotation IS detected — without this, an
# at_start cursor that hits an empty file would forever appear
# to be a 'first poll' (device=0, inode=0) and a later rotation
# would slip through.
if not rotation_detected and start_offset >= stat.st_size:
return HermesLogPoll(
cursor=HermesLogCursor(
path=str(p), device=stat.st_dev, inode=stat.st_ino, offset=stat.st_size
),
records=(),
incidents=(),
rotation_detected=False,
truncated_lines=0,
parsed_line_count=0,
)
records, incidents, new_offset, parsed_count, truncated = _read_segment(
p,
start_offset=start_offset,
max_lines=max_lines,
classifier=classifier_local,
level_filter=level_filter,
since=since,
)
return HermesLogPoll(
cursor=HermesLogCursor(
path=str(p), device=stat.st_dev, inode=stat.st_ino, offset=new_offset
),
records=records,
incidents=incidents,
rotation_detected=rotation_detected,
truncated_lines=truncated,
parsed_line_count=parsed_count,
)
def _is_rotation_or_truncation(cursor: HermesLogCursor, stat: os.stat_result) -> bool:
# First-ever poll: device/inode == 0 (sentinel from at_start). We
# have no prior identity to compare against, so treat as fresh
# read rather than rotation.
if cursor.device == 0 and cursor.inode == 0:
return False
if cursor.device != stat.st_dev or cursor.inode != stat.st_ino:
return True
# File shrank below our last offset → it was truncated; rewind.
return stat.st_size < cursor.offset
def _read_segment(
path: Path,
*,
start_offset: int,
max_lines: int | None,
classifier: IncidentClassifier,
level_filter: frozenset[LogLevel] | None,
since: datetime | None,
) -> tuple[tuple[LogRecord, ...], tuple[HermesIncident, ...], int, int, int]:
"""Read [start_offset, EOF) and return (records, incidents, new_offset,
parsed_count, truncated_lines).
"""
records: list[LogRecord] = []
incidents: list[HermesIncident] = []
parsed_count = 0
truncated = 0
# The previous-level latch lets the parser tag traceback
# continuations with their parent record's severity even when
# the parent landed in an earlier poll. The classifier already
# buffers the open traceback for us across calls.
prev_level: LogLevel | None = None
# Continuation records carry no logger and inherit datetime.min, so
# ``since`` filtering must track the last non-continuation record's
# decision and propagate it to subsequent continuation lines.
#
# The tricky case is two loggers interleaving in the file:
#
# t=20s logger-A: Traceback … → passes since filter
# t=05s logger-B: unrelated → filtered by since filter
# (continuation frame) → belongs to logger-A's traceback,
# must still pass
#
# A scalar "parent_passes_since" would be overwritten by logger-B and
# the continuation would inherit the wrong decision. Instead we keep a
# FIFO queue of filter decisions for Traceback **openers** only (see
# ``_opens_python_traceback``). Non-traceback headers update
# ``last_parent_passes_since`` but are not queued, so a filtered line
# before a passing Traceback does not steal the continuation's decision.
#
# Invariant: in real Python logging a traceback is a single log-call, so
# each header produces exactly one block of consecutive continuations.
# The FIFO pairing matches physical write order.
since_queue: deque[bool] = deque() # one entry per Traceback opener, in order
prev_was_continuation = False # tracks boundary for queue pop
last_parent_passes_since = since is None # seed when queue is empty
# ``new_offset`` is written exactly once per loop iteration from
# ``line_start`` (see comment below). ``while True`` guarantees the loop
# body runs at least once before any reachable return, so no module-level
# seed is needed — adding one would just trip CodeQL
# ``py/multiple-definition``.
with path.open("rb") as handle:
handle.seek(start_offset)
# Cap the maximum bytes we'll read in one call so a runaway
# log can't OOM us. Consume whole lines only: if the next line
# cannot fit entirely in ``budget``, seek back before that line so
# the caller's cursor retries it on the next poll.
budget = _DEFAULT_MAX_BYTES
while True:
line_start = handle.tell()
raw = handle.readline()
# Single ``new_offset`` write per iteration so CodeQL does not
# flag redundant assignments (``py/multiple-definition``). On
# the EOF / budget / max_lines break paths ``line_start`` IS the
# resume offset; on the record-is-None ``continue`` and on the
# normal end-of-body path, the next iteration's ``handle.tell()``
# advances past the consumed line so the next write here
# captures the new end-of-stream cursor.
new_offset = line_start
if not raw:
break
if len(raw) > budget:
handle.seek(line_start)
# Budget exhausted before the next full line could be read.
# Signal truncation when unread bytes remain so callers (e.g.
# ``get_hermes_logs``) set ``has_more=True`` and re-poll; without
# this, ``truncated_lines`` stays 0 while ``cursor.offset`` is
# still before EOF and the agent stops tailing prematurely.
try:
file_size = os.fstat(handle.fileno()).st_size
except OSError:
file_size = line_start
if file_size > line_start:
truncated = max(truncated, 1)
break
budget -= len(raw)
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
record = parse_log_line(line, prev_level=prev_level)
if record is None:
continue
passes_level = level_filter is None or record.level in level_filter
if since is None:
passes_since = True
elif record.is_continuation:
if not prev_was_continuation and since_queue:
# First continuation in a new block: pop the oldest header
# entry (= the header that opened this traceback block).
# The entry is kept at the front so subsequent lines in the
# same block (prev_was_continuation=True) simply peek it.
last_parent_passes_since = since_queue.popleft()
passes_since = last_parent_passes_since
else:
passes_since = record.timestamp >= since
# Push this Traceback opener's decision. Plain log lines do
# not open continuation blocks in our format and must not
# consume FIFO slots ahead of a later Traceback header.
if _opens_python_traceback(record.message):
since_queue.append(passes_since)
last_parent_passes_since = passes_since
prev_was_continuation = record.is_continuation
would_return = passes_level and passes_since
# If this line would become the (max_lines+1)th returned record,
# rewind before it without calling observe(). The cursor must
# retry the same bytes on the next poll; observing here first
# would duplicate classifier incidents when the next poll uses a
# fresh classifier (e.g. get_hermes_logs per call).
if max_lines is not None and len(records) >= max_lines and would_return:
try:
file_size = os.fstat(handle.fileno()).st_size
except OSError:
file_size = line_start
remaining_bytes = max(0, file_size - line_start)
consumed_bytes = max(0, line_start - start_offset)
avg_bytes_per_record = consumed_bytes / max(len(records), 1)
truncated = max(
1,
int(remaining_bytes / max(avg_bytes_per_record, 1.0)),
)
handle.seek(line_start)
break
parsed_count += 1
if not record.is_continuation:
prev_level = record.level
# Classifier always observes the record so traceback
# buffering / warning-burst windows are correct.
for incident in classifier.observe(record):
incidents.append(incident)
if passes_level and passes_since:
records.append(record)
return tuple(records), tuple(incidents), new_offset, parsed_count, truncated
def iter_records(poll: HermesLogPoll) -> Iterable[LogRecord]:
"""Tiny convenience for the common ``for r in poll.records`` path.
Exists so callers don't have to know whether records are a tuple
vs. list vs. generator — keeps signature flexibility for future
streaming variants.
"""
return poll.records
__all__ = [
"HermesLogCursor",
"HermesLogPoll",
"iter_records",
"poll_hermes_logs",
]