"""Session-span segmentation for continuous sessions. Continuous gateway sessions keep the Relay session scope open indefinitely; close-driven export means the session root span (and out-of-turn marks) are unexported until /new or idle-end, and a crash loses the whole segment. Segmentation closes the current session scope at a TURN BOUNDARY and pushes a fresh one, chaining segments via metadata: gateway.telemetry.session_segments.on_compaction (default False) gateway.telemetry.session_segments.max_turns (default 0 = unlimited) Both defaults off => behavior identical to today (no rotation, ever). Rotation never happens mid-turn: compaction only sets rotate_pending, consumed at the next begin_turn before the turn scope pushes. """ from __future__ import annotations import threading from typing import Any import pytest from agent import relay_runtime from agent.relay_runtime import ( RelayRuntime, RelaySessionCoordinator, ) class _ScopeHandle: def __init__(self, name: str, seq: int) -> None: self.name = name self.seq = seq class _FakeScopeModule: def __init__(self, wedge_pop: threading.Event | None = None) -> None: self._wedge = wedge_pop self._seq = 0 self.pushes: list[dict[str, Any]] = [] # {name, metadata, handle} self.pops: list[_ScopeHandle] = [] def push(self, name: str, scope_type: Any, **kwargs: Any) -> _ScopeHandle: self._seq += 1 self.pushes.append( { "name": name, "metadata": dict(kwargs.get("metadata") or {}), "parent": kwargs.get("handle"), "seq": self._seq, } ) return _ScopeHandle(name, self._seq) def pop(self, handle: _ScopeHandle, **kwargs: Any) -> None: if self._wedge is not None: self._wedge.wait() self.pops.append(handle) def event(self, *args: Any, **kwargs: Any) -> None: return None class _FakeSubscribers: def __init__(self) -> None: self.flushed = 0 def flush(self) -> None: self.flushed += 1 class _FakeScopeType: Function = "function" Agent = "agent" class _FakeRelay: def __init__(self, wedge_pop: threading.Event | None = None) -> None: self.scope = _FakeScopeModule(wedge_pop) self.subscribers = _FakeSubscribers() self.ScopeType = _FakeScopeType() def get_scope_stack(self) -> None: return None _LIVE: list[tuple[RelayRuntime, _FakeRelay]] = [] def _make_runtime(fake: _FakeRelay) -> RelayRuntime: runtime = RelayRuntime(relay=fake, profile_key="/tmp/test-profile") _LIVE.append((runtime, fake)) return runtime @pytest.fixture(autouse=True) def _teardown_runtimes(): """Unwedge and drain every runtime so exit paths never replay wedged ops.""" yield for runtime, fake in _LIVE: if fake.scope._wedge is not None: fake.scope._wedge.set() runtime.shutdown() _LIVE.clear() @pytest.fixture(autouse=True) def _fast_scope_timeout(monkeypatch): monkeypatch.setattr(relay_runtime, "_SCOPE_OP_TIMEOUT", 1.0) @pytest.fixture(autouse=True) def _default_config(monkeypatch): """No config on disk by default; tests override _segments_config directly.""" monkeypatch.setattr( "gateway.run._load_gateway_config", lambda: {}, raising=False ) relay_runtime._reset_segments_config_for_tests() def _set_segments(monkeypatch, *, on_compaction=False, max_turns=0): monkeypatch.setattr( "gateway.run._load_gateway_config", lambda: { "gateway": { "telemetry": { "session_segments": { "on_compaction": on_compaction, "max_turns": max_turns, } } } }, raising=False, ) relay_runtime._reset_segments_config_for_tests() @pytest.fixture() def coordinator() -> RelaySessionCoordinator: return RelaySessionCoordinator() def _acquire(coordinator, runtime, session_id="sess-1"): class _Registry: def for_profile(self, key): return runtime coordinator.registry = _Registry() coordinator._prepare_session = lambda host, ctx: None return coordinator.acquire_conversation( profile_key=runtime.profile_key, session_id=session_id, platform="test", ) def _session_pushes(fake): return [p for p in fake.scope.pushes if p["name"] == relay_runtime.SESSION_SCOPE] def _run_turn(coordinator, lease, turn_id): turn = coordinator.begin_turn(lease, turn_id=turn_id, task_id=f"task-{turn_id}") coordinator.end_turn(turn, outcome="success") return turn class TestDefaultsNeverRotate: def test_no_rotation_across_many_turns_and_compactions(self, coordinator): fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) assert lease.session is not None coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) for i in range(5): _run_turn(coordinator, lease, f"t{i}") assert len(_session_pushes(fake)) == 1, ( "defaults off must never rotate the session scope — " "today's behavior is the contract" ) class TestCompactionRotation: def test_compaction_rotates_at_next_begin_turn_not_immediately( self, coordinator, monkeypatch ): _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) original_handle = lease.session.handle coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) # No rotation yet — compaction only flags; scope stack untouched. assert len(_session_pushes(fake)) == 1 assert not fake.scope.pops turn = coordinator.begin_turn(lease, turn_id="t1", task_id="task1") sessions = _session_pushes(fake) assert len(sessions) == 2, "rotation must happen at the next begin_turn" # Old session scope was popped before the new push. assert any(p.seq == 1 for p in fake.scope.pops), "old segment scope popped" assert lease.session.handle is not original_handle # The turn scope parents to the NEW segment handle. turn_push = [p for p in fake.scope.pushes if p["name"] == relay_runtime.TURN_SCOPE][-1] assert turn_push["parent"] is lease.session.handle coordinator.end_turn(turn, outcome="success") def test_segment_metadata_on_rotated_scope(self, coordinator, monkeypatch): _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) _run_turn(coordinator, lease, "t1") new_seg = _session_pushes(fake)[-1]["metadata"] assert new_seg.get("hermes.session.segment") == 1 assert new_seg.get("hermes.session.segment_reason") == "compaction" def test_unknown_session_compaction_is_noop(self, coordinator, monkeypatch): _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) _acquire(coordinator, runtime) # Must not raise, must not rotate anything. coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="never-seen" ) assert len(_session_pushes(fake)) == 1 def test_rotating_compaction_closes_old_session_scope( self, coordinator, monkeypatch ): """Legacy compaction rotates to a child session id: the OLD session's scope must close (export) instead of orphaning unexported forever.""" _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) _acquire(coordinator, runtime, session_id="parent-1") assert not fake.scope.pops coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="child-1", old_session_id="parent-1", ) assert len(fake.scope.pops) == 1, ( "rotating compaction must close the old session scope" ) # Subscriber flushing is process-wide and happens once at final plugin # teardown, after all sessions have drained. Flushing on this per-session # close can block an active asyncio loop owned by another session. assert fake.subscribers.flushed == 0 def test_rotating_compaction_mid_turn_defers_close_to_end_turn( self, coordinator, monkeypatch ): """A rotating compaction completing while a turn is LIVE on the old session must NOT close the session scope immediately — that would pop it under the live turn scope (LIFO violation). The close defers to that turn's end_turn.""" _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime, session_id="parent-1") turn = coordinator.begin_turn(lease, turn_id="t1", task_id="task1") coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="child-1", old_session_id="parent-1", ) # No pops yet: neither the turn scope nor the session scope closed. assert not fake.scope.pops, ( "old-session close must defer while its turn is live" ) coordinator.end_turn(turn, outcome="success") # Turn scope popped first, then the deferred session close popped # the session scope — LIFO order preserved. assert len(fake.scope.pops) == 2, "end_turn must consume deferred close" assert fake.scope.pops[0].name == relay_runtime.TURN_SCOPE, ( "turn scope must pop before the session scope" ) assert fake.scope.pops[-1].name == relay_runtime.SESSION_SCOPE assert runtime.get_session("parent-1") is None def test_rotating_compaction_noop_when_disabled(self, coordinator, monkeypatch): fake = _FakeRelay() runtime = _make_runtime(fake) _acquire(coordinator, runtime, session_id="parent-1") coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="child-1", old_session_id="parent-1", ) assert not fake.scope.pops, "defaults off: rotating compaction is a no-op" class TestMaxTurnsRotation: def test_rotates_after_cap(self, coordinator, monkeypatch): _set_segments(monkeypatch, max_turns=2) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) for i in range(5): _run_turn(coordinator, lease, f"t{i}") # turns 0,1 in segment 0; rotation before turn 2; turns 2,3 in # segment 1; rotation before turn 4. sessions = _session_pushes(fake) assert len(sessions) == 3, "cap of 2 over 5 turns => 2 rotations" assert sessions[-1]["metadata"].get("hermes.session.segment_reason") == "max_turns" def test_zero_cap_means_unlimited(self, coordinator, monkeypatch): _set_segments(monkeypatch, max_turns=0) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) for i in range(4): _run_turn(coordinator, lease, f"t{i}") assert len(_session_pushes(fake)) == 1 class TestRotationSafety: def test_never_rotates_mid_turn(self, coordinator, monkeypatch): _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) turn = coordinator.begin_turn(lease, turn_id="t1", task_id="task1") # Compaction lands while the turn is LIVE. coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) assert len(_session_pushes(fake)) == 1, "no rotation while a turn is live" coordinator.end_turn(turn, outcome="success") assert len(_session_pushes(fake)) == 1, "end_turn does not rotate either" # The NEXT turn consumes the pending rotation. turn2 = coordinator.begin_turn(lease, turn_id="t2", task_id="task2") assert len(_session_pushes(fake)) == 2 coordinator.end_turn(turn2, outcome="success") def test_wedged_rotation_is_bounded_and_agent_continues( self, coordinator, monkeypatch ): _set_segments(monkeypatch, on_compaction=True) wedge = threading.Event() # never set until teardown fake = _FakeRelay(wedge_pop=wedge) runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) result: list[Any] = [] def _begin(): result.append( coordinator.begin_turn(lease, turn_id="t1", task_id="task1") ) worker = threading.Thread(target=_begin, daemon=True) worker.start() worker.join(5.0) assert not worker.is_alive(), ( "begin_turn must return even when the rotation pop wedges — " "a wedged pipeline costs one segment span, never the agent" ) turn = result[0] coordinator.end_turn(turn, outcome="success") def test_subagent_children_parent_to_new_segment_after_rotation( self, coordinator, monkeypatch ): _set_segments(monkeypatch, on_compaction=True) fake = _FakeRelay() runtime = _make_runtime(fake) lease = _acquire(coordinator, runtime) coordinator.notify_session_compacted( profile_key=runtime.profile_key, session_id="sess-1" ) _run_turn(coordinator, lease, "t1") # consumes rotation new_handle = lease.session.handle child = runtime.register_subagent( {"parent_session_id": "sess-1", "child_session_id": "child-1"} ) assert child is not None child_push = [ p for p in fake.scope.pushes if p["name"] == relay_runtime.SESSION_SCOPE and p["parent"] is not None ][-1] assert child_push["parent"] is new_handle, ( "post-rotation children must parent to the new segment handle" )