diff --git a/gateway/run.py b/gateway/run.py index 76526eba8c..91e7f513bc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -30196,6 +30196,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop cleanup_video_cache, ) from tools.tool_result_storage import cleanup_spillover_cache + from tools.bot_mode_dm import cleanup_bot_dm_cache from hermes_cli.debug import _sweep_expired_pastes IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval @@ -30215,6 +30216,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop ("Video", cleanup_video_cache), ("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache), + ("Bot DM", cleanup_bot_dm_cache), ) logger.info("Gateway housekeeping started (interval=%ds)", interval) diff --git a/tests/tools/test_bot_dm_payload_cache.py b/tests/tools/test_bot_dm_payload_cache.py new file mode 100644 index 0000000000..1cbcfdcd0f --- /dev/null +++ b/tests/tools/test_bot_dm_payload_cache.py @@ -0,0 +1,74 @@ +"""DM payload files must be reaped by gateway housekeeping, not only in-band. + +`message_agent` writes the message body to a file and hands the path to a +*background* delivery, so it cannot be removed at the call site. The runner +owns per-delivery cleanup, and `_write_dm_file` sweeps opportunistically — +but a gateway that never sends another DM would still keep orphans forever. +`cleanup_bot_dm_cache` follows the same contract as the other +``cleanup_*_cache`` helpers (returns the number of files removed) so the +gateway housekeeping loop in ``gateway/run.py`` prunes this cache on the +same hourly cadence as the media caches. +""" + +import os +import time +from pathlib import Path + +import pytest + +from tools import bot_mode_dm + + +@pytest.fixture() +def temp_root(tmp_path, monkeypatch): + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + return tmp_path + + +def _age(path: Path, seconds: float) -> None: + past = time.time() - seconds + os.utime(path, (past, past)) + + +class TestCleanupContract: + def test_expired_payloads_are_removed_and_counted(self, temp_root): + old = Path(bot_mode_dm._write_dm_file("stale")) + fresh = Path(bot_mode_dm._write_dm_file("recent")) + _age(old, bot_mode_dm._DM_STALE_SECONDS + 1) + + removed = bot_mode_dm.cleanup_bot_dm_cache() + + assert removed == 1 + assert not old.exists() + assert fresh.exists(), "a payload still in flight was reaped" + + def test_cleanup_reports_how_many_it_removed(self, temp_root): + # write all files first: _write_dm_file itself sweeps opportunistically + paths = [Path(bot_mode_dm._write_dm_file("x")) for _ in range(3)] + for p in paths: + _age(p, bot_mode_dm._DM_STALE_SECONDS + 1) + assert bot_mode_dm.cleanup_bot_dm_cache() == 3 + + def test_a_missing_dm_dir_is_not_an_error(self, temp_root): + assert bot_mode_dm.cleanup_bot_dm_cache() == 0 + + def test_legacy_and_relay_prefixed_orphans_are_swept(self, temp_root): + legacy = temp_root / "hermes-dm-legacy.txt" + relay = temp_root / "hermes-relay-dm-orphan.txt" + unrelated = temp_root / "other.txt" + for f in (legacy, relay, unrelated): + f.write_text("secret", encoding="utf-8") + _age(f, bot_mode_dm._DM_STALE_SECONDS + 1) + + removed = bot_mode_dm.cleanup_bot_dm_cache() + + assert removed == 2 + assert not legacy.exists() + assert not relay.exists() + assert unrelated.exists() + + def test_shorter_max_age_hours_is_honored(self, temp_root): + recent = Path(bot_mode_dm._write_dm_file("an hour old")) + _age(recent, 2 * 3600) + assert bot_mode_dm.cleanup_bot_dm_cache(max_age_hours=1) == 1 + assert not recent.exists() diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 04698a1495..2eda26eadb 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -439,22 +439,47 @@ def _dm_dir() -> Path: return path -def _sweep_stale_dm_files(*, now: float | None = None) -> None: - """Best-effort cleanup for files orphaned before their runner started.""" - cutoff = (time.time() if now is None else now) - _DM_STALE_SECONDS - # Include the legacy temp-root location so upgrades clean files created by - # versions predating the dedicated directory. - locations = ((Path(tempfile.gettempdir()), "hermes-dm-*.txt"), (_dm_dir(), "*.txt")) +def cleanup_bot_dm_cache( + max_age_hours: float = _DM_STALE_SECONDS / 3600, *, now: float | None = None +) -> int: + """Delete orphaned DM payload files older than *max_age_hours*. + + Same contract as the other ``cleanup_*_cache`` helpers — returns the + number of files removed — so the gateway housekeeping loop can prune + this cache on the same hourly cadence as the media caches, even on + installs that never send another DM (the in-band sweep in + ``_write_dm_file`` only runs when a DM is written). + """ + cutoff = (time.time() if now is None else now) - max_age_hours * 3600 + removed = 0 + # Include the legacy temp-root locations so upgrades clean files created + # by versions predating the dedicated directory. + temp_root = Path(tempfile.gettempdir()) + locations: list[tuple[Path, str]] = [ + (temp_root, "hermes-dm-*.txt"), + (temp_root, "hermes-relay-dm-*.txt"), + ] + try: + locations.append((_dm_dir(), "*.txt")) + except OSError: + pass for directory, pattern in locations: try: for candidate in directory.glob(pattern): try: if candidate.is_file() and candidate.stat().st_mtime < cutoff: candidate.unlink() + removed += 1 except OSError: pass except OSError: pass + return removed + + +def _sweep_stale_dm_files(*, now: float | None = None) -> None: + """Best-effort cleanup for files orphaned before their runner started.""" + cleanup_bot_dm_cache(now=now) def _write_dm_file(content: str) -> str: