Files
hermes-agent/tests/plugins/test_nemo_relay_bounded_marks.py
T
Teknium fe0a56ed16 fix(nemo_relay): bound plugin Relay marks so a wedged native pipeline cannot stall the agent
The plugin's _Runtime.run_in_session wrapper serves every mark/event it
emits (turn start/end, approvals, subagent marks) and runs synchronously
on the agent's conversation thread. It passed no timeout, so the host's
run_in_session default (timeout=None) made each mark an UNBOUNDED native
call. With a wedged native Relay pipeline the agent blocked between API
calls with zero activity ticks — observed live 2026-08-15: two cron jobs
died at the 600s inactivity kill and a gateway chat session at 1800s,
all with last_activity="API call #N completed".

The core's scope push/pop/flush/close sites were bounded with
_SCOPE_OP_TIMEOUT after the 2026-08-10 delegation stall; the plugin's
event marks were the missed sibling class.

Changes:
- plugins/observability/nemo_relay: the wrapper always passes
  timeout=relay_runtime._SCOPE_OP_TIMEOUT (10s) to the host. A breach
  costs one telemetry span, never the agent; it also sets scope_errored
  (so close_session skips the ATIF export for the wedged session) and
  warns once so the sick pipeline is visible.
- tests/plugins/test_nemo_relay_bounded_marks.py: proves the budget
  reaches the host (fails on the pre-fix code — sabotage-verified),
  a TimeoutError flags the session and disables its export, and the
  generic error path keeps its scope_errored contract.
2026-08-15 14:31:32 -07:00

102 lines
3.5 KiB
Python

"""Plugin Relay marks must be bounded — never an unbounded native call.
The nemo_relay plugin's ``_Runtime.run_in_session`` wrapper serves every
mark/event the plugin emits (turn start/end, approvals, subagent marks) and
runs synchronously on the agent's conversation thread. The host's
``run_in_session`` default (``timeout=None``) is an unbounded native call: a
wedged native Relay pipeline blocked the agent between API calls with zero
activity ticks until the cron 600s inactivity kill fired (observed live
2026-08-15 — two cron jobs and a gateway chat session died with
``last_activity=API call #N completed``).
The wrapper now always passes ``timeout=relay_runtime._SCOPE_OP_TIMEOUT`` to
the host, and a breach flags ``scope_errored`` so ``close_session`` skips the
ATIF export for the wedged session.
"""
from __future__ import annotations
import importlib
import sys
from types import SimpleNamespace
import pytest
from agent import relay_runtime as core_relay_runtime
def _plugin_module():
mod = sys.modules.get("plugins.observability.nemo_relay")
if mod is None:
mod = importlib.import_module("plugins.observability.nemo_relay")
return mod
def _make_runtime_and_state(host):
plugin = _plugin_module()
runtime = object.__new__(plugin._Runtime)
runtime.host = host
state = plugin._SessionState(session_id="s-bound")
state.relay_session = SimpleNamespace(session_id="s-bound")
return plugin, runtime, state
def test_plugin_marks_pass_bounded_timeout_to_host():
"""Every wrapper dispatch carries the core scope-op budget."""
seen = {}
class _Host:
def run_in_session(self, session, callback, *args, timeout=None, **kwargs):
seen["timeout"] = timeout
return callback(*args, **kwargs)
_plugin, runtime, state = _make_runtime_and_state(_Host())
result = runtime.run_in_session(state, lambda: "ok")
assert result == "ok"
assert seen["timeout"] == core_relay_runtime._SCOPE_OP_TIMEOUT
assert state.scope_errored is False
def test_timeout_breach_flags_session_and_skips_atif_export():
"""A wedged native call costs one span and disables the session export."""
class _WedgedHost:
def run_in_session(self, session, callback, *args, timeout=None, **kwargs):
raise TimeoutError(
f"Relay scope operation exceeded {timeout}s (session=s-bound)"
)
plugin, runtime, state = _make_runtime_and_state(_WedgedHost())
with pytest.raises(TimeoutError):
runtime.run_in_session(state, lambda: "never")
assert state.scope_errored is True
# export_atif must now skip without touching the exporter.
class _ExplodingExporter:
def export_json(self): # pragma: no cover - must not be called
raise AssertionError("export ran for a scope-errored session")
state.atif_exporter = _ExplodingExporter()
runtime.settings = plugin._Settings(
atif_enabled=True, atif_output_directory="/tmp/never-used"
)
runtime.export_atif(state) # no raise, no export
def test_non_timeout_errors_still_flag_session():
"""The pre-existing generic error path keeps its scope_errored contract."""
class _BrokenHost:
def run_in_session(self, session, callback, *args, timeout=None, **kwargs):
raise RuntimeError("scope handle is not at the top of the stack")
_plugin, runtime, state = _make_runtime_and_state(_BrokenHost())
with pytest.raises(RuntimeError):
runtime.run_in_session(state, lambda: "never")
assert state.scope_errored is True