mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(sessions): auto-prune is opt-in again; ask once when the disk runs low
sessions.auto_prune goes back to false by default. Deleting history is the user's call, but an install that never decides still needs to hear about it when the disk holding state.db runs low, or state.db grows until the disk fills (the reason #54189 turned it on). While the user's config.yaml has no sessions.auto_prune key, every surface asks once the disk is low (< 2 GiB free, or < 10% free with < 20 GiB left): - classic CLI: a y / n / Enter(later) question at startup (TTY only) - TUI and Desktop: one sticky notification.show per profile per process - gateway: the question on each served profile's home channel, at most once per 7-day snooze window - /autoprune on|off|later|status answers it on every surface (registry executor, so the text is identical); on/off writes the explicit key and the question never comes back, later snoozes a week The prompt estimates what a prune would free (read-only count of ended sessions past retention_days, scaled by their share of messages) and says plainly when pruning will not fix the disk: no session old enough, or only a small amount reclaimable. The hot-path check is os.stat + shutil.disk_usage + one small YAML read; the DB estimate only runs once that gate fires, off the prompt thread on TUI/Desktop. gateway/run.py and tui_gateway stay under their line caps: the media-cache chore moves to the new gateway/run_housekeeping_disk.py with the prompt chore.
This commit is contained in:
1 parent
f6e272304e
commit
968c87f6bc
17 files changed
+495
-53
No files matched your search
@@ -1035,10 +1035,11 @@ group_sessions_per_user: true
|
||||
# Session Storage Retention (state.db)
|
||||
# =============================================================================
|
||||
# ~/.hermes/state.db keeps every session, message, and tool call, plus the
|
||||
# FTS5 search indexes. Since #54189, auto-pruning is ON by default so the file
|
||||
# stays bounded: at CLI/gateway/cron startup (at most once per
|
||||
# min_interval_hours) Hermes deletes ENDED sessions whose last activity is
|
||||
# older than retention_days. Open, pinned, and in-progress sessions are never
|
||||
# FTS5 search indexes. Auto-pruning is OFF by default; when enabled, at
|
||||
# CLI/gateway/cron startup (at most once per min_interval_hours) Hermes deletes
|
||||
# ENDED sessions whose last activity is older than retention_days. While
|
||||
# auto_prune is unset, Hermes asks once when the disk holding state.db runs
|
||||
# low (/autoprune on|off|later answers it). Open, pinned, and in-progress sessions are never
|
||||
# deleted. Stale automation sessions (cron/kanban/subagent/one-shot CLI) whose
|
||||
# process died without closing them are first *closed*, then aged through a
|
||||
# further full retention window before removal.
|
||||
@@ -1048,10 +1049,10 @@ group_sessions_per_user: true
|
||||
# of the file's pages are reclaimable — a dense database never pays for a full
|
||||
# rewrite to reclaim a few MB.
|
||||
#
|
||||
# Uncomment to change the defaults shown; set auto_prune: false to keep every
|
||||
# ended session forever (the pre-#54189 behavior).
|
||||
# Uncomment to change the defaults shown; auto_prune: true bounds the file,
|
||||
# auto_prune: false keeps every ended session and silences the low-disk prompt.
|
||||
# sessions:
|
||||
# auto_prune: true
|
||||
# auto_prune: false
|
||||
# retention_days: 90
|
||||
# vacuum_after_prune: true
|
||||
# min_vacuum_interval_days: 30
|
||||
|
||||
+4
-25
@@ -4356,30 +4356,6 @@ def _housekeeping_channel_directory(adapters, loop) -> None:
|
||||
fut.result(timeout=30)
|
||||
|
||||
|
||||
def _housekeeping_media_caches() -> None:
|
||||
"""Every platform media cache prunes on the same hourly cadence (24h max age)."""
|
||||
from gateway.platforms.base import (
|
||||
cleanup_audio_cache, cleanup_document_cache, cleanup_image_cache, cleanup_screenshot_cache,
|
||||
cleanup_video_cache)
|
||||
from tools.tool_result_storage import cleanup_spillover_cache
|
||||
from tools.environments.local import cleanup_terminal_temp_cache
|
||||
from tools.bot_mode_dm import cleanup_bot_dm_cache
|
||||
from tools.bot_relay import cleanup_bot_relay_artifacts
|
||||
from agent.provider_media import MEDIA_CACHE_MAX_AGE_HOURS
|
||||
|
||||
for cache_name, cleanup_fn in (
|
||||
("Image", cleanup_image_cache), ("Document", cleanup_document_cache),
|
||||
("Audio", cleanup_audio_cache), ("Video", cleanup_video_cache),
|
||||
("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache),
|
||||
("Terminal temp", cleanup_terminal_temp_cache), ("Bot DM", cleanup_bot_dm_cache),
|
||||
("Bot relay", cleanup_bot_relay_artifacts)):
|
||||
def _one(name=cache_name, fn=cleanup_fn):
|
||||
removed = fn(max_age_hours=MEDIA_CACHE_MAX_AGE_HOURS)
|
||||
if removed:
|
||||
logger.info("%s cache cleanup: removed %d stale file(s)", name, removed)
|
||||
_housekeeping_chore(f"{cache_name} cache cleanup", _one)
|
||||
|
||||
|
||||
def _housekeeping_paste_sweep() -> None:
|
||||
from hermes_cli.debug import _sweep_expired_pastes
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
@@ -4535,6 +4511,7 @@ def _start_gateway_housekeeping(
|
||||
Cadences are ticks of ``interval``; inner gates own the real cadence."""
|
||||
from gateway.run_delivery_queue_watch import DRAIN_LABEL, DeliveryQueueWatch, wait_for_next_tick
|
||||
from gateway.run_profile_reconcile import _mcp_config_reconciler, profile_scoped_chore
|
||||
from gateway.run_housekeeping_disk import housekeeping_media_caches, maybe_post_auto_prune_prompt
|
||||
chores: list[tuple[int, str, Any]] = [
|
||||
# First every tick: re-stamp ``updated_at`` in gateway_state.json so it is a real heartbeat.
|
||||
# ``hermes gateway status`` / ``/api/status`` warn when it ages past 2x ``interval`` with the
|
||||
@@ -4547,8 +4524,10 @@ def _start_gateway_housekeeping(
|
||||
chores.append((1, DRAIN_LABEL, lambda: _drain_restart_safe_cron_deliveries(adapters, loop, runner)))
|
||||
chores += [
|
||||
(5, "Channel directory refresh", lambda: adapters and _housekeeping_channel_directory(adapters, loop)),
|
||||
(60, "Media cache cleanup", _housekeeping_media_caches),
|
||||
(60, "Media cache cleanup", housekeeping_media_caches),
|
||||
(60, "Paste sweep", _housekeeping_paste_sweep)]
|
||||
# Low disk + auto-prune undecided: ask on the home channel (two syscalls; snoozed per post).
|
||||
chores.append((1, "Auto-prune low-disk prompt", lambda: maybe_post_auto_prune_prompt(runner, loop)))
|
||||
if cron_provider is not None:
|
||||
chores.append((5, "Misfire catch-up sweep", lambda: _housekeeping_misfire_catch_up(cron_provider, adapters, loop)))
|
||||
if cron_thread is not None:
|
||||
|
||||
+1
-1
@@ -986,7 +986,7 @@ class GatewayBusySessionMixin:
|
||||
_PLAIN_COMMANDS = (
|
||||
"status", "context", "restart", "approve", "deny", "pause", "agents", "bg", "btw",
|
||||
"kanban", "subgoal", "heartbeat", "busy", "yolo", "verbose", "footer", "help",
|
||||
"commands", "profile", "login", "update", "version",
|
||||
"commands", "profile", "login", "update", "version", "autoprune",
|
||||
)
|
||||
# Dispatched only on the idle path (busy dispatch has its own allowlist).
|
||||
_IDLE_COMMANDS = (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Gateway housekeeping chores that keep the data disk healthy: stale media-cache cleanup and the
|
||||
home-channel prompt for the low-disk auto-prune decision (see ``hermes_cli/prune_advisor.py``).
|
||||
|
||||
A headless gateway on a small VPS is where a disk fills up, and its owner may never open the CLI,
|
||||
so the question goes to each served profile's home channel. It is posted at most once per
|
||||
snooze window per profile: posting records the snooze, and ``/autoprune on|off`` ends it for good.
|
||||
Runs from the housekeeping thread; the send is scheduled onto the gateway loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def housekeeping_media_caches() -> None:
|
||||
"""Every platform media cache prunes on the same hourly cadence (24h max age)."""
|
||||
from gateway.platforms.base import (
|
||||
cleanup_audio_cache, cleanup_document_cache, cleanup_image_cache, cleanup_screenshot_cache,
|
||||
cleanup_video_cache)
|
||||
from tools.tool_result_storage import cleanup_spillover_cache
|
||||
from tools.environments.local import cleanup_terminal_temp_cache
|
||||
from tools.bot_mode_dm import cleanup_bot_dm_cache
|
||||
from tools.bot_relay import cleanup_bot_relay_artifacts
|
||||
from agent.provider_media import MEDIA_CACHE_MAX_AGE_HOURS
|
||||
from gateway.run import _housekeeping_chore
|
||||
|
||||
for cache_name, cleanup_fn in (
|
||||
("Image", cleanup_image_cache), ("Document", cleanup_document_cache),
|
||||
("Audio", cleanup_audio_cache), ("Video", cleanup_video_cache),
|
||||
("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache),
|
||||
("Terminal temp", cleanup_terminal_temp_cache), ("Bot DM", cleanup_bot_dm_cache),
|
||||
("Bot relay", cleanup_bot_relay_artifacts)):
|
||||
def _one(name=cache_name, fn=cleanup_fn):
|
||||
removed = fn(max_age_hours=MEDIA_CACHE_MAX_AGE_HOURS)
|
||||
if removed:
|
||||
logger.info("%s cache cleanup: removed %d stale file(s)", name, removed)
|
||||
_housekeeping_chore(f"{cache_name} cache cleanup", _one)
|
||||
|
||||
|
||||
def maybe_post_auto_prune_prompt(runner, loop) -> None:
|
||||
"""One housekeeping tick: post the prompt to every served profile whose disk is low and who
|
||||
has not decided. Cheap when nothing is due (two syscalls + a YAML read per profile)."""
|
||||
if runner is None or loop is None:
|
||||
return
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
from hermes_cli import prune_advisor
|
||||
from hermes_constants import get_routing_process_hermes_home
|
||||
|
||||
homes: dict = {None: get_routing_process_hermes_home()}
|
||||
homes.update(getattr(runner, "_served_profile_homes", None) or {})
|
||||
for profile, home in homes.items():
|
||||
if prune_advisor.should_ask(Path(home)) is None:
|
||||
continue
|
||||
advice = prune_advisor.advise(Path(home))
|
||||
if advice is None:
|
||||
continue
|
||||
fut = safe_schedule_threadsafe(_post(runner, profile, home, advice), loop, logger=logger,
|
||||
log_message="Auto-prune prompt scheduling error")
|
||||
# Snooze only on a delivered post: a gateway with no home channel (the one a CLI
|
||||
# auto-starts) must not swallow the question the CLI/TUI is about to ask.
|
||||
if fut is not None and fut.result(timeout=60):
|
||||
prune_advisor.snooze(Path(home))
|
||||
|
||||
|
||||
async def _post(runner, profile, home, advice) -> bool:
|
||||
"""Send to the profile's home channels; True when at least one send was delivered."""
|
||||
from gateway.run import _async_profile_runtime_scope
|
||||
from gateway.warning_notifications import present_notification
|
||||
from hermes_cli.prune_advisor import advice_lines
|
||||
|
||||
delivered = False
|
||||
scope = _async_profile_runtime_scope(Path(home)) if profile is not None else contextlib.nullcontext()
|
||||
async with scope:
|
||||
for target_profile, platform, _cfg, home_ch, transport in list(runner._served_home_channel_transports()):
|
||||
if target_profile != profile:
|
||||
continue
|
||||
prefix = getattr(transport.adapter, "typed_command_prefix", "/") if transport.adapter else "/"
|
||||
message = "⚠ " + "\n".join(advice_lines(advice, command_prefix=prefix))
|
||||
sent: list[bool] = []
|
||||
|
||||
async def _send(platform=platform, home_ch=home_ch, transport=transport, message=message):
|
||||
sent.append(await runner._send_home_channel_message(
|
||||
platform, home_ch, transport, message, "Auto-prune prompt failed for %s:%s: %s"))
|
||||
|
||||
await present_notification(_send, platform=platform)
|
||||
delivered = delivered or any(sent)
|
||||
return delivered
|
||||
@@ -24,7 +24,7 @@ _CONTROL_COMMANDS = frozenset({'title'})
|
||||
# cached agent, the /debug summary upload, /blueprint's job creation. A turn the handler would have
|
||||
# pushed onto the adapter FIFO comes back as a ``send`` directive (gateway/session_command_sink.py).
|
||||
_HANDLER_CONTROLS = frozenset({'goal', 'subgoal', 'loop', 'heartbeat', 'review', 'refine', 'debug', 'curator',
|
||||
'login'})
|
||||
'login', 'autoprune'})
|
||||
# Idle-path commands the messaging gateway answers by rewriting the turn (``_hm_cmd_<name>``,
|
||||
# gateway/run_inbound.py): the rewritten text is returned as the client's ``send`` directive.
|
||||
_PROMPT_COMMANDS = frozenset({'plan', 'learn', 'init', 'moa', 'blueprint', 'egress'})
|
||||
|
||||
@@ -595,6 +595,10 @@ class GatewaySlashCommandsMixin(
|
||||
return t("gateway.draining", count=active_agents)
|
||||
return EphemeralReply(t("gateway.restart.restarting"))
|
||||
|
||||
async def _handle_autoprune_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /autoprune — the low-disk auto-prune decision (same text as the CLI)."""
|
||||
return _execute("autoprune", args=event.get_command_args()).text
|
||||
|
||||
async def _handle_version_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /version — show the running Hermes Agent version."""
|
||||
return _execute("version").text
|
||||
|
||||
@@ -270,6 +270,11 @@ class CLICommandsSessionToolsMixin:
|
||||
tools_disable_enable_command(Namespace(**ns))
|
||||
_cp(*buf.getvalue().splitlines())
|
||||
|
||||
def _handle_autoprune_command(self, cmd: str):
|
||||
"""/autoprune [on|off|later|status] — the low-disk auto-prune decision."""
|
||||
from hermes_cli.slash_exec import CommandContext, execute_command
|
||||
_say_block(execute_command("autoprune", CommandContext(surface="cli", args=cmd.partition(" ")[2])).text)
|
||||
|
||||
def _handle_profile_command(self):
|
||||
"""Display active profile name and home directory."""
|
||||
from hermes_cli.slash_exec import CommandContext, execute_command
|
||||
|
||||
@@ -293,6 +293,13 @@ class CLITuiRuntimeMixin:
|
||||
offer_interactive(cprint=_cprint)
|
||||
except Exception:
|
||||
logger.debug("sandbox image switch offer failed", exc_info=True)
|
||||
# Low disk + auto-prune never decided: ask once (either answer ends it for good).
|
||||
try:
|
||||
from hermes_cli.prune_advisor import offer_interactive as _offer_auto_prune
|
||||
from cli import _cprint
|
||||
_offer_auto_prune(cprint=_cprint)
|
||||
except Exception:
|
||||
logger.debug("auto-prune offer failed", exc_info=True)
|
||||
|
||||
if self._resumed and self._preload_resumed_session():
|
||||
self._display_resumed_history()
|
||||
|
||||
@@ -238,6 +238,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="[queue|steer|interrupt|status]",
|
||||
subcommands=("queue", "steer", "interrupt", "status"),
|
||||
busy_policy="dispatch", desktop="terminal"),
|
||||
CommandDef("autoprune", "Turn automatic pruning of old sessions on or off", "Configuration",
|
||||
args_hint="[on|off|later|status]", subcommands=("on", "off", "later", "status"),
|
||||
busy_policy="dispatch", execute="autoprune", argument_mode="options"),
|
||||
|
||||
# Tools & Skills
|
||||
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
||||
|
||||
@@ -383,7 +383,9 @@ _SLACK_RESERVED_COMMANDS = frozenset({
|
||||
# parity test reads this set. Aliases are never pinned ahead of canonicals.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({
|
||||
"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat",
|
||||
"refine", "review", "pause", "whoami", "platform", "insights", "login"})
|
||||
"refine", "review", "pause", "whoami", "platform", "insights", "login",
|
||||
# One-time answer to the low-disk prompt; `/hermes autoprune on` reaches it.
|
||||
"autoprune"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
||||
@@ -2263,18 +2263,14 @@ DEFAULT_CONFIG = {
|
||||
# inserts, /resume listing, and insights queries.
|
||||
"sessions": {
|
||||
# Prune ENDED sessions inactive for retention_days (activity = freshest of live activity /
|
||||
# latest message / creation) about once per min_interval_hours at startup. Open, pinned, or mid-turn sessions
|
||||
# are never deleted; stale automation sessions whose process died are *closed*, then get a
|
||||
# full retention window before removal.
|
||||
"auto_prune": True,
|
||||
# latest message / creation) about once per min_interval_hours at CLI/gateway/cron startup.
|
||||
# Open, pinned, or mid-turn sessions are never deleted; stale automation sessions whose
|
||||
# process died are *closed*, then get a full retention window before removal.
|
||||
# Off by default (deleting history is opt-in). While the user's config.yaml has no
|
||||
# auto_prune key, every surface asks once the disk holding state.db runs low
|
||||
# (hermes_cli/prune_advisor.py); either answer writes the key and ends the question.
|
||||
"auto_prune": False,
|
||||
# Inactive days of ended-session history to keep (= `hermes sessions prune`).
|
||||
# When true, prune ENDED sessions inactive for retention_days once per (roughly) min_interval_hours
|
||||
# at CLI/gateway/cron startup. Activity is the freshest of live activity (last_activity_at) / latest
|
||||
# message timestamp / creation time. Sessions that are still open, pinned, or mid-turn are never deleted — the
|
||||
# only open rows the sweep touches are stale automation sessions (cron/kanban/subagent/one-shot CLI)
|
||||
# whose process died without closing them; those are *closed*, not deleted, and get a further full
|
||||
# retention window before removal. Default true since #54189: without it state.db grows without
|
||||
# bound (multi-GB installs reported within weeks).
|
||||
"retention_days": 90,
|
||||
# Auto-archive (soft-hide, never delete) sessions with no activity for auto_archive_days,
|
||||
# once per min_interval_hours. Pinned sessions are exempt.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Low-disk prompt for ``sessions.auto_prune`` (off by default).
|
||||
|
||||
Auto-prune deletes history, so it is opt-in. Users who never decided still need to hear about it
|
||||
when the disk holding ``state.db`` runs low: every surface asks once the disk is low, and the
|
||||
question stops forever as soon as ``sessions.auto_prune`` is written to the user's config.yaml
|
||||
(either answer). "Later" snoozes for :data:`SNOOZE_SECONDS`.
|
||||
|
||||
The hot-path check is two syscalls (``os.stat`` + ``shutil.disk_usage``, microseconds), so it can
|
||||
run at every session start. Only when it fires does :func:`advise` open the database read-only to
|
||||
estimate what a prune would actually free, so the prompt can say plainly when pruning will not
|
||||
fix the disk (nothing old enough to delete, or the store is a small share of the full disk).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GIB = 1024 ** 3
|
||||
# Low = under 2 GiB free, or under 10% free while also under 20 GiB (a 10%-free 4 TB disk is fine).
|
||||
LOW_FREE_FLOOR = 2 * GIB
|
||||
LOW_FREE_PERCENT = 10.0
|
||||
LOW_FREE_HEADROOM = 20 * GIB
|
||||
SNOOZE_SECONDS = 7 * 86400
|
||||
_SNOOZE_FILE = ".auto_prune_prompt.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiskSnapshot:
|
||||
db_bytes: int
|
||||
free_bytes: int
|
||||
total_bytes: int
|
||||
|
||||
@property
|
||||
def low(self) -> bool:
|
||||
if self.total_bytes <= 0:
|
||||
return False
|
||||
free_pct = self.free_bytes * 100.0 / self.total_bytes
|
||||
return self.free_bytes < LOW_FREE_FLOOR or (
|
||||
free_pct < LOW_FREE_PERCENT and self.free_bytes < LOW_FREE_HEADROOM)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Advice:
|
||||
disk: DiskSnapshot
|
||||
retention_days: int
|
||||
prunable_sessions: int
|
||||
total_sessions: int
|
||||
reclaim_bytes: int # estimate: the prunable share of messages times the file size
|
||||
|
||||
@property
|
||||
def helps(self) -> bool:
|
||||
"""Whether a prune would free a meaningful amount: at least 1 GiB, or enough to lift the
|
||||
disk back out of the low band."""
|
||||
if self.prunable_sessions == 0:
|
||||
return False
|
||||
lifted = DiskSnapshot(self.disk.db_bytes - self.reclaim_bytes,
|
||||
self.disk.free_bytes + self.reclaim_bytes, self.disk.total_bytes)
|
||||
return self.reclaim_bytes >= GIB or not lifted.low
|
||||
|
||||
|
||||
def _home(home: Optional[Path]) -> Path:
|
||||
if home is not None:
|
||||
return Path(home)
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home()
|
||||
|
||||
|
||||
def disk_snapshot(home: Optional[Path] = None) -> Optional[DiskSnapshot]:
|
||||
"""``state.db`` size plus free/total bytes of its filesystem; None when either is unreadable."""
|
||||
db = _home(home) / "state.db"
|
||||
try:
|
||||
size = os.stat(db).st_size
|
||||
usage = shutil.disk_usage(db.parent)
|
||||
except OSError:
|
||||
return None
|
||||
return DiskSnapshot(size, usage.free, usage.total)
|
||||
|
||||
|
||||
def undecided(home: Optional[Path] = None) -> bool:
|
||||
"""True while the user's own config.yaml has no ``sessions.auto_prune`` (default not chosen)."""
|
||||
import hermes_yaml as yaml
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
try:
|
||||
sessions = read_user_config_raw(_home(home) / "config.yaml").get("sessions")
|
||||
except (OSError, ValueError, yaml.YAMLError): # unreadable/broken config: never nag on top of it
|
||||
return False
|
||||
return not (isinstance(sessions, dict) and "auto_prune" in sessions)
|
||||
|
||||
|
||||
def _snooze_path(home: Optional[Path]) -> Path:
|
||||
return _home(home) / _SNOOZE_FILE
|
||||
|
||||
|
||||
def snoozed(home: Optional[Path] = None, *, now: Optional[float] = None) -> bool:
|
||||
try:
|
||||
until = float(json.loads(_snooze_path(home).read_text(encoding="utf-8-sig")).get("snoozed_until", 0))
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return (now or time.time()) < until
|
||||
|
||||
|
||||
def snooze(home: Optional[Path] = None, seconds: float = SNOOZE_SECONDS) -> None:
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(_snooze_path(home), {"snoozed_until": time.time() + seconds})
|
||||
|
||||
|
||||
def should_ask(home: Optional[Path] = None) -> Optional[DiskSnapshot]:
|
||||
"""The cheap gate every surface runs at session start: the snapshot when the disk is low and
|
||||
the user has neither decided nor snoozed, else None. Two syscalls plus one small YAML read."""
|
||||
snap = disk_snapshot(home)
|
||||
if snap is None or not snap.low:
|
||||
return None
|
||||
if not undecided(home) or snoozed(home):
|
||||
return None
|
||||
return snap
|
||||
|
||||
|
||||
def advise(home: Optional[Path] = None) -> Optional[Advice]:
|
||||
""":func:`should_ask` plus the reclaim estimate. Opens state.db read-only (a second or two on
|
||||
a multi-GB store), so callers run it only after the cheap gate fires or off the prompt thread."""
|
||||
snap = should_ask(home)
|
||||
if snap is None:
|
||||
return None
|
||||
from hermes_cli.config import load_config_readonly
|
||||
retention = int(((load_config_readonly() or {}).get("sessions") or {}).get("retention_days", 90) or 90)
|
||||
prunable = total = prunable_msgs = total_msgs = 0
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=_home(home) / "state.db", read_only=True)
|
||||
try:
|
||||
where, params = db._prune_where(retention, None, {}, whole_lineages=True)
|
||||
prunable, prunable_msgs = tuple(db._read_one(
|
||||
f"SELECT COUNT(*), COALESCE(SUM(s.message_count), 0) FROM sessions s WHERE {where}", params) or (0, 0))
|
||||
total, total_msgs = tuple(db._read_one(
|
||||
"SELECT COUNT(*), COALESCE(SUM(message_count), 0) FROM sessions", []) or (0, 0))
|
||||
finally:
|
||||
db.close()
|
||||
except (sqlite3.Error, OSError, RuntimeError, ValueError) as exc: # a busy/damaged store: no estimate
|
||||
logger.debug("auto-prune advice: reclaim estimate unavailable: %s", exc)
|
||||
reclaim = int(snap.db_bytes * prunable_msgs / total_msgs) if total_msgs else 0
|
||||
return Advice(snap, retention, int(prunable), int(total), reclaim)
|
||||
|
||||
|
||||
def _gb(n: int) -> str:
|
||||
"""Human size: GB at or above 1 GiB, MB below (a 26 MB store must not read "0.0 GB")."""
|
||||
return f"{n / GIB:.1f} GB" if n >= GIB else f"{max(n, 0) / 1024 ** 2:.0f} MB"
|
||||
|
||||
|
||||
def _sessions(n: int) -> str:
|
||||
return f"{n} session" + ("" if n == 1 else "s")
|
||||
|
||||
|
||||
def advice_lines(advice: Advice, *, command_prefix: str = "/") -> list[str]:
|
||||
"""Plain-text prompt shared by every surface (no markup); the last line names the commands."""
|
||||
d = advice.disk
|
||||
lines = [
|
||||
f"Low disk space: {_gb(d.free_bytes)} free of {_gb(d.total_bytes)}. "
|
||||
f"Your session history (state.db) uses {_gb(d.db_bytes)}.",
|
||||
f"Auto-prune is off. Turning it on deletes ended sessions inactive for more than "
|
||||
f"{advice.retention_days} days, once a day (pinned and open sessions are kept).",
|
||||
]
|
||||
if advice.prunable_sessions == 0:
|
||||
held = (f"none of your {_sessions(advice.total_sessions)} is older than {advice.retention_days} days"
|
||||
if advice.total_sessions else "there is no session history to delete")
|
||||
lines.append(f"Pruning will not help right now: {held}. Something else is filling this disk.")
|
||||
elif not advice.helps:
|
||||
lines.append(f"Pruning would free only about {_gb(advice.reclaim_bytes)} "
|
||||
f"({_sessions(advice.prunable_sessions)}), which will not fix this disk on its own.")
|
||||
else:
|
||||
lines.append(f"Pruning would free about {_gb(advice.reclaim_bytes)} "
|
||||
f"({advice.prunable_sessions} of {_sessions(advice.total_sessions)}).")
|
||||
p = command_prefix
|
||||
lines.append(f"{p}autoprune on to enable, {p}autoprune off to keep everything (never asked again), "
|
||||
f"{p}autoprune later to be reminded in a week.")
|
||||
return lines
|
||||
|
||||
|
||||
def decide(enable: bool, home: Optional[Path] = None) -> bool:
|
||||
"""Persist the user's answer as an explicit ``sessions.auto_prune``; ends the prompt for good."""
|
||||
import hermes_yaml as yaml
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
from utils import atomic_roundtrip_yaml_update
|
||||
path = _home(home) / "config.yaml"
|
||||
try:
|
||||
mkdir_under_hermes_home(path.parent)
|
||||
atomic_roundtrip_yaml_update(path, "sessions.auto_prune", bool(enable))
|
||||
except (OSError, ValueError, yaml.YAMLError) as exc:
|
||||
logger.warning("Could not save sessions.auto_prune: %s", exc)
|
||||
return False
|
||||
try:
|
||||
_snooze_path(home).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def run_command(args: str, home: Optional[Path] = None) -> str:
|
||||
"""``/autoprune [on|off|later|status]`` — the one handler behind the CLI, TUI, Desktop and
|
||||
messaging surfaces."""
|
||||
from hermes_cli.config import load_config_readonly
|
||||
arg = (args or "").strip().lower()
|
||||
if arg in {"on", "enable", "yes"}:
|
||||
return ("Auto-prune is on: ended sessions inactive past retention_days are deleted once a day."
|
||||
if decide(True, home) else "Could not save sessions.auto_prune; check config.yaml permissions.")
|
||||
if arg in {"off", "disable", "no"}:
|
||||
return ("Auto-prune is off and you will not be asked again. `hermes sessions prune` still works any time."
|
||||
if decide(False, home) else "Could not save sessions.auto_prune; check config.yaml permissions.")
|
||||
if arg in {"later", "snooze"}:
|
||||
snooze(home)
|
||||
return "OK, I'll ask again in a week if the disk is still low."
|
||||
sessions = (load_config_readonly() or {}).get("sessions") or {}
|
||||
state = "on" if sessions.get("auto_prune") else "off"
|
||||
chosen = "default" if undecided(home) else "your setting"
|
||||
snap = disk_snapshot(home)
|
||||
disk = (f" state.db {_gb(snap.db_bytes)}; {_gb(snap.free_bytes)} free of {_gb(snap.total_bytes)}"
|
||||
f"{' (low)' if snap.low else ''}.") if snap else ""
|
||||
return (f"Auto-prune is {state} ({chosen}), retention {sessions.get('retention_days', 90)} days.{disk} "
|
||||
"Usage: /autoprune on | off | later")
|
||||
|
||||
|
||||
def offer_interactive(*, cprint, ask=input) -> Optional[bool]:
|
||||
"""Classic-CLI startup offer (TTY only). Returns the decision, or None when nothing was asked
|
||||
or the user chose later."""
|
||||
advice = advise()
|
||||
if advice is None:
|
||||
return None
|
||||
cprint("")
|
||||
cprint("☤ " + advice_lines(advice)[0])
|
||||
for line in advice_lines(advice)[1:-1]:
|
||||
cprint(f" {line}")
|
||||
try:
|
||||
answer = ask(" Enable auto-prune? [y = enable / n = never prune, don't ask again / Enter = remind me later]: ")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return None
|
||||
answer = answer.strip().lower()
|
||||
if answer in {"y", "yes"}:
|
||||
decide(True)
|
||||
cprint(" ✓ sessions.auto_prune: true")
|
||||
return True
|
||||
if answer in {"n", "no"}:
|
||||
decide(False)
|
||||
cprint(" ✓ sessions.auto_prune: false. You won't be asked again.")
|
||||
return False
|
||||
snooze()
|
||||
cprint(" Later. /autoprune on|off decides it any time.")
|
||||
return None
|
||||
@@ -76,6 +76,12 @@ def _exec_profile(ctx: CommandContext) -> CommandReply:
|
||||
data={"profile": profile_name, "home": home_display})
|
||||
|
||||
|
||||
def _exec_autoprune(ctx: CommandContext) -> CommandReply:
|
||||
"""Core /autoprune text — record the auto-prune decision or show its state."""
|
||||
from hermes_cli.prune_advisor import run_command
|
||||
return CommandReply(run_command(ctx.args))
|
||||
|
||||
|
||||
def _exec_bundles(ctx: CommandContext) -> CommandReply:
|
||||
"""Core /bundles data — installed skill bundles listing."""
|
||||
try:
|
||||
@@ -180,6 +186,7 @@ EXECUTORS: dict[str, Callable[[CommandContext], CommandReply]] = {
|
||||
"egress": _exec_egress,
|
||||
"profile": _exec_profile,
|
||||
"bundles": _exec_bundles,
|
||||
"autoprune": _exec_autoprune,
|
||||
"gateway_help": _exec_help,
|
||||
"gateway_commands": _exec_commands}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Low-disk auto-prune prompt: asks only while undecided, says when pruning will not help."""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import prune_advisor
|
||||
|
||||
_Usage = namedtuple("_Usage", "total used free")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home(tmp_path, monkeypatch):
|
||||
from hermes_state import SessionDB
|
||||
h = tmp_path / "home"
|
||||
h.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(h))
|
||||
db = SessionDB(db_path=h / "state.db")
|
||||
db.create_session("recent", source="cli")
|
||||
db.append_message("recent", role="user", content="hi")
|
||||
db.end_session("recent", "user_exit")
|
||||
db.close()
|
||||
return h
|
||||
|
||||
|
||||
def _disk(monkeypatch, free_gb, total_gb=100):
|
||||
gib = prune_advisor.GIB
|
||||
monkeypatch.setattr(shutil, "disk_usage", lambda _p: _Usage(total_gb * gib, (total_gb - free_gb) * gib, free_gb * gib))
|
||||
|
||||
|
||||
def test_asks_only_when_low_and_undecided_and_either_answer_ends_it(home, monkeypatch):
|
||||
_disk(monkeypatch, free_gb=50)
|
||||
assert prune_advisor.should_ask(home) is None # plenty of space: silent
|
||||
_disk(monkeypatch, free_gb=1)
|
||||
assert prune_advisor.should_ask(home) is not None
|
||||
prune_advisor.snooze(home)
|
||||
assert prune_advisor.should_ask(home) is None # "later" holds for the window
|
||||
prune_advisor._snooze_path(home).unlink()
|
||||
for answer in (False, True):
|
||||
assert prune_advisor.decide(answer, home)
|
||||
assert (home / "config.yaml").read_text(encoding="utf-8-sig").count("auto_prune") == 1
|
||||
assert prune_advisor.should_ask(home) is None # an explicit key ends the question
|
||||
|
||||
|
||||
def test_prompt_says_pruning_will_not_help_when_nothing_is_old_enough(home, monkeypatch):
|
||||
_disk(monkeypatch, free_gb=1)
|
||||
advice = prune_advisor.advise(home)
|
||||
assert advice is not None and advice.total_sessions == 1 and advice.prunable_sessions == 0
|
||||
assert not advice.helps
|
||||
assert "Pruning will not help" in "\n".join(prune_advisor.advice_lines(advice))
|
||||
# Backdate the session past retention: now it is prunable and the prompt offers it.
|
||||
import sqlite3
|
||||
old = time.time() - 200 * 86400
|
||||
with sqlite3.connect(home / "state.db") as conn:
|
||||
conn.execute("UPDATE sessions SET started_at=?, ended_at=?, last_activity_at=?", (old, old, old))
|
||||
conn.execute("UPDATE messages SET timestamp=?", (old,))
|
||||
advice = prune_advisor.advise(home)
|
||||
assert advice.prunable_sessions == 1
|
||||
assert "will not help" not in "\n".join(prune_advisor.advice_lines(advice))
|
||||
@@ -364,6 +364,31 @@ def _init_session(
|
||||
_schedule_mcp_late_refresh(sid, agent)
|
||||
|
||||
|
||||
_AUTO_PRUNE_NOTIFIED: set[str] = set()
|
||||
|
||||
|
||||
def _maybe_notify_auto_prune(sid: str, profile_home: str | None) -> None:
|
||||
"""Low disk + auto-prune never decided: one sticky notice (TUI status line / Desktop toast)
|
||||
per profile per process, on session create and cold resume. The cheap gate runs inline;
|
||||
the DB estimate runs off-thread."""
|
||||
from hermes_cli import prune_advisor
|
||||
from hermes_constants import get_hermes_home
|
||||
home = Path(profile_home) if profile_home else get_hermes_home()
|
||||
if str(home) in _AUTO_PRUNE_NOTIFIED or prune_advisor.should_ask(home) is None:
|
||||
return
|
||||
_AUTO_PRUNE_NOTIFIED.add(str(home))
|
||||
|
||||
def _work() -> None:
|
||||
advice = prune_advisor.advise(home)
|
||||
if advice is not None:
|
||||
_emit("notification.show", sid, {
|
||||
"text": "⚠ " + " ".join(prune_advisor.advice_lines(advice)), "level": "warn",
|
||||
"kind": "sticky", "ttl_ms": None, "key": "sessions.auto_prune", "id": "sessions.auto_prune"})
|
||||
|
||||
from agent.memory_provider import spawn_context_thread
|
||||
spawn_context_thread(_work, name="auto-prune-advice").start()
|
||||
|
||||
|
||||
def _new_session_key() -> str:
|
||||
return new_session_id()
|
||||
|
||||
@@ -522,6 +547,7 @@ def _schedule_agent_build(sid: str, delay: float = 0.05) -> None:
|
||||
def _run():
|
||||
if (session := _sessions.get(sid)) is not None:
|
||||
_start_agent_build(sid, session)
|
||||
_maybe_notify_auto_prune(sid, session.get("profile_home"))
|
||||
timer = threading.Timer(delay, _run)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
@@ -26,13 +26,14 @@ narrower routes (`hermes_cli/gateway_chat_commands.py`).
|
||||
setting to this local session (its next turn and `config.get`), saving config.yaml only where the
|
||||
handler does; `client-local (cli_only)` commands are terminal/UI features of a client.
|
||||
|
||||
102 registry commands: 28 control, 18 read, 56 refused.
|
||||
103 registry commands: 29 control, 18 read, 56 refused.
|
||||
|
||||
| Command | Gateway (`slash.exec`) | Classic CLI | Ink | Desktop | ACP | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/agents` (/tasks) | refused | HermesCLI._handle_agents_command | delegation.pause | slash.exec: refused | refused | gateway-wide: lists every chat's running agents and the process's async jobs |
|
||||
| `/approvals` | control | HermesCLI._handle_approvals_command | slash.exec | slash.exec | refused | per-session setting (gateway handler) |
|
||||
| `/approve` | control | messaging-only: unknown command | slash.exec | slash.exec | refused | messaging-only |
|
||||
| `/autoprune` | control | HermesCLI._handle_autoprune_command | slash.exec | slash.exec | refused | |
|
||||
| `/battery` | refused | HermesCLI._handle_battery_command | sidecar: system.battery (preference not saved) | slash.exec: refused | refused | client-local (cli_only) |
|
||||
| `/bg` | refused | HermesCLI._handle_background_command | prompt.background | action: background | refused | |
|
||||
| `/blueprint` (/bp) | control | HermesCLI._handle_blueprint_command | slash.exec | slash.exec | refused | |
|
||||
|
||||
@@ -1140,24 +1140,25 @@ Key tables in `state.db`:
|
||||
|
||||
- Gateway conversations persist across inactivity; use `/new` or `/reset` for an explicit boundary
|
||||
- Before reset, the agent saves memories and skills from the expiring session
|
||||
- Auto-pruning (**on by default** since #54189): when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway/cron startup
|
||||
- Auto-pruning (**off by default**; opt in): when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway/cron startup
|
||||
- **Low-disk prompt.** While your `config.yaml` has no `sessions.auto_prune` key, Hermes checks the disk holding `state.db` at session start (two filesystem calls, no measurable cost). When that disk is low (under 2 GB free, or under 10% free with less than 20 GB left) it asks once: the classic CLI asks at startup, the TUI and Desktop show a notice, and a gateway posts the question to its home channel. The prompt estimates what a prune would free and says plainly when pruning will *not* fix the disk (no session older than `retention_days`, or the history is a small share of the full disk). Answer with `/autoprune on`, `/autoprune off` (keep everything; never asked again) or `/autoprune later` (reminded in a week); `/autoprune` alone shows the current state
|
||||
- `sessions.retention_days` must be a whole number of days `>= 0`. A negative value (or a missing one) is rejected: startup maintenance logs a warning naming the allowed range and skips the sweep instead of treating the future cutoff as "everything" — `sessions.auto_prune: false` is the switch that disables pruning
|
||||
- After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space only when **both** gates pass: at least `sessions.min_vacuum_interval_days` (default 30) have elapsed since the last successful `VACUUM`, **and** more than 25% of the file's pages are reclaimable (`PRAGMA freelist_count / page_count`). A dense database never pays for a full rewrite to reclaim a few MB (SQLite does not shrink the file on plain DELETE)
|
||||
- Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME`
|
||||
|
||||
Without pruning, `state.db` grows without bound — multi-GB files within weeks were reported on gateway + cron installs. If you would rather keep every ended session forever (the pre-#54189 behavior), turn it off in `~/.hermes/config.yaml`:
|
||||
Without pruning, `state.db` grows without bound — multi-GB files within weeks were reported on gateway + cron installs. To bound it, turn it on in `~/.hermes/config.yaml` (or answer `/autoprune on`):
|
||||
|
||||
```yaml
|
||||
sessions:
|
||||
auto_prune: false # default is true — set false to keep all history
|
||||
auto_prune: true # default is false — true deletes ended sessions past retention_days
|
||||
retention_days: 90 # keep ended sessions active within this window
|
||||
vacuum_after_prune: true # reclaim disk space after a pruning sweep
|
||||
min_vacuum_interval_days: 30 # don't rewrite the DB more often than this
|
||||
min_interval_hours: 24 # don't re-run the sweep more often than this
|
||||
```
|
||||
|
||||
Existing installs that already set any of these keys explicitly keep their
|
||||
values; only unset keys pick up the new defaults.
|
||||
Installs that set any of these keys explicitly keep their values; only unset
|
||||
keys follow the defaults.
|
||||
|
||||
Only **ended** sessions are ever deleted. Active sessions are never auto-pruned,
|
||||
regardless of age. Ended sessions are aged from their last activity — the
|
||||
@@ -1232,5 +1233,5 @@ hermes sessions prune --older-than 30 --yes
|
||||
```
|
||||
|
||||
:::tip
|
||||
Auto-prune is **on by default**: ended sessions that have been inactive for `sessions.retention_days` (default 90) are removed at startup, and active sessions are never touched (see [Automatic Cleanup](#automatic-cleanup) above). Session history powers `session_search` recall across past conversations, so if you want to keep every ended session forever, set `sessions.auto_prune: false` in `config.yaml`, or raise `retention_days`. With auto-prune off, `hermes sessions prune` remains available for one-off cleanup (observed failure mode without any pruning: a 384 MB `state.db` with ~1000 sessions slowing down FTS5 inserts and `/resume` listing).
|
||||
Auto-prune is **off by default**, and Hermes asks you once when the disk holding `state.db` runs low (see [Automatic Cleanup](#automatic-cleanup) above). When on, ended sessions inactive for `sessions.retention_days` (default 90) are removed at startup and active sessions are never touched. Session history powers `session_search` recall across past conversations, so the choice is yours: `/autoprune on|off`, or `sessions.auto_prune` in `config.yaml`. With auto-prune off, `hermes sessions prune` remains available for one-off cleanup (observed failure mode without any pruning: a 384 MB `state.db` with ~1000 sessions slowing down FTS5 inserts and `/resume` listing).
|
||||
:::
|
||||
Reference in new issue
Block a user