Files
hermes-webui/tests/test_issue4029_stale_completion_age_gate.py
T
50e7589a92 Release PP (v0.51.455): age-gate stale background-process completions (#4306, #4029) (#4307)
_drain_webui_process_notifications now drops (consumes, never requeues) a
completion whose completed_at is older than a configurable cap
(HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS, default 6h; 0 disables).
Fail-open: events without completed_at (legacy agent) are never dropped.
Additive (+192/-0). Closes #4029.

Co-authored-by: allenliang2022

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-06-16 06:58:38 -07:00

126 lines
4.5 KiB
Python

"""Behavioral tests for issue #4029 stale-completion age gate.
These exercise the real `_drain_webui_process_notifications` against a fake
process_registry, proving that:
- a completion older than the cap is dropped (consumed, not requeued),
- a fresh completion is still delivered,
- events without `completed_at` are never dropped (backward compat),
- the env override (incl. disable via 0) is honored.
"""
import importlib
import queue
import sys
import threading
import time
import types
import pytest
def _install_fake_registry(monkeypatch, events):
"""Build a fake `tools.process_registry` module whose `process_registry`
exposes the surface `_drain_webui_process_notifications` touches."""
q = queue.Queue()
for e in events:
q.put(e)
class _FakeProc:
def __init__(self, session_key):
self.session_key = session_key
class _FakeRegistry:
def __init__(self):
self.completion_queue = q
self._lock = threading.RLock()
self._completion_consumed = set()
# map session_id -> session_key so ownership check passes
self._owner = {}
def is_completion_consumed(self, sid):
return sid in self._completion_consumed
def get(self, sid):
key = self._owner.get(sid)
return _FakeProc(key) if key is not None else None
reg = _FakeRegistry()
mod = types.ModuleType("tools.process_registry")
mod.process_registry = reg
# ensure parent package exists
if "tools" not in sys.modules:
pkg = types.ModuleType("tools")
pkg.__path__ = []
monkeypatch.setitem(sys.modules, "tools", pkg)
monkeypatch.setitem(sys.modules, "tools.process_registry", mod)
return reg
def _make_event(sid, completed_at, session_key="websess-1"):
return {
"type": "completion",
"session_id": sid,
"session_key": session_key,
"command": f"cmd-{sid}",
"exit_code": 0,
"output": f"out-{sid}",
**({"completed_at": completed_at} if completed_at is not None else {}),
}
@pytest.fixture
def streaming():
return importlib.import_module("api.streaming")
def test_stale_completion_is_dropped_and_fresh_is_delivered(streaming, monkeypatch):
now = time.time()
fresh = _make_event("fresh", now - 60) # 1 min old -> keep
stale = _make_event("stale", now - 7 * 3600) # 7 h old -> drop (cap 6h)
reg = _install_fake_registry(monkeypatch, [stale, fresh])
reg._owner["fresh"] = "websess-1"
reg._owner["stale"] = "websess-1"
# default cap = 6h
monkeypatch.delenv("HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS", raising=False)
out = streaming._drain_webui_process_notifications("websess-1")
joined = "\n".join(out)
assert "fresh" in joined, "fresh completion should be delivered"
assert "stale" not in joined, "stale completion must be dropped"
# stale was consumed (won't come back), fresh consumed after delivery
assert "stale" in reg._completion_consumed
assert "fresh" in reg._completion_consumed
# nothing belonging to this session is left requeued
assert reg.completion_queue.empty()
def test_event_without_completed_at_is_never_dropped(streaming, monkeypatch):
legacy = _make_event("legacy", None) # no completed_at -> backward compat keep
reg = _install_fake_registry(monkeypatch, [legacy])
reg._owner["legacy"] = "websess-1"
monkeypatch.delenv("HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS", raising=False)
out = streaming._drain_webui_process_notifications("websess-1")
assert any("legacy" in n for n in out), "legacy event (no timestamp) must be delivered"
def test_env_zero_disables_age_gate(streaming, monkeypatch):
now = time.time()
ancient = _make_event("ancient", now - 10 * 24 * 3600) # 10 days old
reg = _install_fake_registry(monkeypatch, [ancient])
reg._owner["ancient"] = "websess-1"
monkeypatch.setenv("HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS", "0")
out = streaming._drain_webui_process_notifications("websess-1")
assert any("ancient" in n for n in out), "age gate disabled -> even ancient delivered"
def test_helper_reads_env_override(streaming, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS", "120")
assert streaming._stale_completion_max_age_seconds() == 120.0
monkeypatch.setenv("HERMES_WEBUI_STALE_COMPLETION_MAX_AGE_SECONDS", "not-a-number")
# invalid -> falls back to default 6h
assert streaming._stale_completion_max_age_seconds() == 6 * 60 * 60