fix(bot-mode): reap orphaned DM payloads from gateway housekeeping

The in-band sweep in _write_dm_file only runs when another DM is
written — a gateway that never sends one keeps orphans forever. Expose
the sweep as cleanup_bot_dm_cache() with the same contract as the other
cleanup_*_cache helpers (returns files removed) and wire it into the
gateway housekeeping loop on the hourly media-cache cadence. Also sweeps
legacy hermes-dm-*.txt and hermes-relay-dm-*.txt orphans in the OS temp
root.

Folded in from #92407 (mehmetkr-31), adapted to the runner-owned
cleanup design salvaged from #91902.
This commit is contained in:
mehmetkr-31
2026-08-23 02:57:41 -07:00
committed by Teknium
parent 08742d0e32
commit 793fba428a
3 changed files with 107 additions and 6 deletions
+2
View File
@@ -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)
+74
View File
@@ -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()
+31 -6
View File
@@ -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: