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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user