Files
hermes-agent/tests/gateway/test_completion_session_boundary.py
T
Teknium 7619564fbd fix(gateway): gate background-process completions on spawning-session boundary
Plain type=completion events built in _run_process_watcher carried only
session_key (chat/thread routing) with no spawning-session stamp, so after
/new (or a session switch) a completion notification from the OLD session
was injected into the chat's NEW session. Main already solved this exact
class for async delegations via the _classify_completion_target pre-flight
(_USER_BOUNDARY_END_REASONS drop on user-closed sessions, deliver on
idle-ends, follow the compression-tip chain), but the gate only ran for
type=async_delegation events.

Kernel salvage of #16455:

- Stamp the spawning conversation's session-db id (HERMES_SESSION_ID via
  session-scoped env) on the ProcessSession and the pending_watchers entry
  at spawn time in tools/terminal_tool.py; persist it through the process
  registry checkpoint/restore so recovered watchers keep the stamp.
- Thread the stamp into the completion_evt built by _run_process_watcher
  (watcher entry first, ProcessSession fallback for recovered watchers).
- In _deliver_completion_notification, run the SAME pre-flight classifier
  for stamped type=completion events: terminal -> drop with a log (output
  stays available via process(action='log')), retry -> False so the
  watcher re-polls, deliver -> proceed. The policy has exactly one owner
  (_classify_completion_target); nothing is forked. Unstamped legacy
  events keep today's deliver-always behavior, and the async-delegation
  path is untouched.

Based on the session-boundary approach from #16455 by @Tosko4 (original PR
was over-scoped across adapters/slash-commands/cron; this lands the kernel
only).

Tests: completion from a /new-closed session is dropped; completion after
an idle-end still delivers; unstamped legacy event delivers; retry verdict
returns retryable False without adapter injection; async_delegation gate
unchanged; stamp survives checkpoint recovery.
2026-08-14 01:09:42 -07:00

337 lines
11 KiB
Python

"""Session-boundary gating for background-process completion delivery.
Plain ``type=completion`` events historically carried only ``session_key``
(chat/thread routing), so a background process spawned in session A whose
completion fired after ``/new`` was injected into the chat's NEW session.
The fix stamps the spawning conversation's session-db id on the watcher at
spawn time and routes stamped events through the SAME pre-flight policy the
async-delegation path already uses (``_classify_completion_target``):
- terminal (user boundary such as /new) -> drop with a log
- retry (transient DB uncertainty) -> watcher re-polls
- deliver (live / idle-ended parent) -> proceed as today
Unstamped legacy events keep today's deliver-always behavior.
"""
import asyncio
import json
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform
from gateway.run import GatewayRunner
from tools.process_registry import ProcessRegistry, ProcessSession
@pytest.fixture(autouse=True)
def isolated_registry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import tools.process_registry as pr_module
monkeypatch.setattr(pr_module, "CHECKPOINT_PATH", tmp_path / "processes.json")
registry = pr_module.ProcessRegistry()
monkeypatch.setattr(pr_module, "process_registry", registry)
return registry
class _SessionDB:
def __init__(self, row, tip=None):
self._row = row
self._tip = tip
async def get_session(self, session_id):
return self._row
async def get_compression_tip(self, session_id):
return self._tip
def _runner(adapter, *, session_db=...):
runner = object.__new__(GatewayRunner)
runner._running = True
runner.adapters = {Platform.TELEGRAM: adapter}
runner.session_store = SimpleNamespace(
_ensure_loaded=lambda: None,
_entries={},
)
runner._session_source_cache = {}
runner._completion_delivery_lock = __import__("threading").Lock()
runner._completion_deliveries_inflight = set()
runner._completion_deliveries_delivered = OrderedDict()
runner._completion_delivery_retention = 2048
if session_db is not ...:
runner._session_db = session_db
return runner
def _finished_session(registry, session_id="proc_boundary", **kwargs):
session = ProcessSession(
id=session_id,
command="echo done",
task_id="task",
started_at=1234.5,
output_buffer="done\n",
exited=True,
exit_code=0,
notify_on_complete=True,
**kwargs,
)
registry._finished[session.id] = session
return session
def _watcher(session_id, parent_session_id=None):
watcher = {
"session_id": session_id,
"check_interval": 0,
"session_key": "agent:main:telegram:dm:123",
"platform": "telegram",
"chat_type": "dm",
"chat_id": "123",
"notify_on_complete": True,
}
if parent_session_id is not None:
watcher["parent_session_id"] = parent_session_id
return watcher
def _run_watcher(monkeypatch, runner, watcher):
async def _instant_sleep(*_a, **_kw):
pass
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
asyncio.run(runner._run_process_watcher(watcher))
def _completion_evt(parent_session_id=None, session_id="proc_x"):
evt = {
"type": "completion",
"session_id": session_id,
"session_key": "agent:main:telegram:dm:123",
"platform": "telegram",
"chat_type": "dm",
"chat_id": "123",
"started_at": 1234.5,
"command": "echo done",
"exit_code": 0,
"completion_reason": "exited",
"output": "done\n",
}
if parent_session_id is not None:
evt["parent_session_id"] = parent_session_id
return evt
# ---------------------------------------------------------------------------
# The stamp is threaded from the watcher into the completion event
# ---------------------------------------------------------------------------
def test_watcher_stamps_parent_session_id_on_completion_event(
monkeypatch, isolated_registry,
):
_finished_session(isolated_registry)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
captured = {}
async def _capture(_text, evt):
captured.update(evt)
return True
monkeypatch.setattr(runner, "_deliver_completion_notification", _capture)
_run_watcher(
monkeypatch, runner, _watcher("proc_boundary", "sess-spawner"),
)
assert captured.get("type") == "completion"
assert captured.get("parent_session_id") == "sess-spawner"
def test_watcher_falls_back_to_process_session_stamp(
monkeypatch, isolated_registry,
):
"""Watchers recovered without the stamp still pick it up off the
ProcessSession (spawn-time stamp survives checkpoint/restore there)."""
_finished_session(
isolated_registry, parent_session_id="sess-from-registry",
)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
captured = {}
async def _capture(_text, evt):
captured.update(evt)
return True
monkeypatch.setattr(runner, "_deliver_completion_notification", _capture)
_run_watcher(monkeypatch, runner, _watcher("proc_boundary"))
assert captured.get("parent_session_id") == "sess-from-registry"
# ---------------------------------------------------------------------------
# Pre-flight verdicts on stamped completion events
# ---------------------------------------------------------------------------
def test_completion_from_user_closed_session_is_dropped(
monkeypatch, isolated_registry,
):
"""/new closed the spawning session -> the stamped completion must NOT
land in the chat's new session."""
_finished_session(isolated_registry)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(
adapter,
session_db=_SessionDB(
{"ended_at": 1786288000.0, "end_reason": "session_reset"}
),
)
_run_watcher(
monkeypatch, runner, _watcher("proc_boundary", "sess-closed"),
)
adapter.handle_message.assert_not_awaited()
def test_completion_after_idle_end_still_delivers(
monkeypatch, isolated_registry,
):
"""Idle/timeout ends are the relay-plane norm — the chat stays routable
and the completion must deliver."""
_finished_session(isolated_registry)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(
adapter,
session_db=_SessionDB(
{"ended_at": 1786288000.0, "end_reason": "idle_timeout"}
),
)
_run_watcher(
monkeypatch, runner, _watcher("proc_boundary", "sess-idle"),
)
adapter.handle_message.assert_awaited_once()
def test_completion_from_live_session_delivers(monkeypatch, isolated_registry):
_finished_session(isolated_registry)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter, session_db=_SessionDB({"ended_at": None}))
_run_watcher(
monkeypatch, runner, _watcher("proc_boundary", "sess-live"),
)
adapter.handle_message.assert_awaited_once()
def test_unstamped_legacy_completion_delivers(monkeypatch, isolated_registry):
"""Events without the spawn-time stamp keep today's behavior even when a
session DB is present."""
_finished_session(isolated_registry)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter, session_db=_SessionDB(None))
_run_watcher(monkeypatch, runner, _watcher("proc_boundary"))
adapter.handle_message.assert_awaited_once()
def test_retry_verdict_returns_false_for_watcher_repoll():
"""No session DB yet -> transient uncertainty -> retryable False, and no
adapter injection happens."""
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter, session_db=None)
result = asyncio.run(
runner._deliver_completion_notification(
"text", _completion_evt("sess-uncertain"),
)
)
assert result is False
adapter.handle_message.assert_not_awaited()
def test_terminal_verdict_returns_none_without_injection():
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter, session_db=_SessionDB(None))
result = asyncio.run(
runner._deliver_completion_notification(
"text", _completion_evt("sess-gone"),
)
)
assert result is None
adapter.handle_message.assert_not_awaited()
# ---------------------------------------------------------------------------
# The async-delegation path is unaffected
# ---------------------------------------------------------------------------
def test_async_delegation_gate_unchanged():
"""A stamped async_delegation event still routes through the existing
delegation-owned gate (terminal verdict -> None), proving the completion
branch did not fork or shadow the delegation policy."""
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter, session_db=_SessionDB(None))
evt = {
"type": "async_delegation",
"delegation_id": "",
"session_key": "agent:main:telegram:dm:12345:678",
"parent_session_id": "sess-gone",
"status": "completed",
}
result = asyncio.run(runner._deliver_completion_notification("text", evt))
assert result is None
adapter.handle_message.assert_not_awaited()
# ---------------------------------------------------------------------------
# The stamp survives checkpoint/restore
# ---------------------------------------------------------------------------
def test_parent_session_id_survives_checkpoint_recovery(tmp_path, monkeypatch):
import tools.process_registry as pr_module
checkpoint = tmp_path / "processes.json"
checkpoint.write_text(json.dumps([{
"session_id": "proc_recovered",
"command": "sleep 999",
"pid": 4242,
"pid_scope": "host",
"host_start_time": 111.0,
"started_at": 1234.5,
"task_id": "task",
"session_key": "agent:main:telegram:dm:123",
"watcher_platform": "telegram",
"watcher_chat_id": "123",
"watcher_interval": 5,
"notify_on_complete": True,
"parent_session_id": "sess-spawner",
}]), encoding="utf-8")
monkeypatch.setattr(pr_module, "CHECKPOINT_PATH", checkpoint)
registry = ProcessRegistry()
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda *_a: True)
monkeypatch.setattr(
registry, "_write_checkpoint", lambda *_a, **_kw: None,
)
assert registry.recover_from_checkpoint() == 1
assert registry.get("proc_recovered").parent_session_id == "sess-spawner"
assert len(registry.pending_watchers) == 1
assert registry.pending_watchers[0]["parent_session_id"] == "sess-spawner"