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