feat(sessions): any prune filter matches all ages; preview shows age span (#59415)

Bare 'hermes sessions prune' keeps the historical 90-day default, but any
filter — now including --source — suppresses the implicit cutoff, so
'prune --source cron' targets ALL cron sessions instead of silently only
those older than 90 days (the surprise a user hit live: 'No sessions
match ... source cron' despite plenty of recent cron runs).

- CLI preview + confirmation now show the match count plus the oldest
  and newest matching session start times before deleting.
- Dashboard /api/sessions/prune mirrors the semantics: attribute filters
  without an explicit older_than_days match all ages (model_fields_set
  distinguishes an explicit 90 from the Pydantic default); dry_run
  responses gain oldest_started_at/newest_started_at.
- Docs + argparse help updated; tests for both surfaces.
This commit is contained in:
Teknium 2026-07-05 23:01:10 -07:00 committed by GitHub
parent 590a19332e
commit 845a2d8152
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 173 additions and 18 deletions

View file

@ -13525,8 +13525,9 @@ def main():
)
_add_session_filter_args(
sessions_prune,
"Delete sessions older than AGE — days if bare number (default: 90), "
"or a duration like '5h'/'2d'/'1w', or an ISO timestamp",
"Delete sessions older than AGE — days if bare number, or a duration "
"like '5h'/'2d'/'1w', or an ISO timestamp (bare prune with no filters "
"defaults to 90 days; any filter matches all ages)",
)
sessions_prune.add_argument(
"--include-archived",
@ -13741,16 +13742,16 @@ def main():
format_epoch,
)
# Preserve the historical default: bare `hermes sessions prune`
# means "older than 90 days" — and --source keeps that default
# too (it predates the extended filters; scripts may rely on
# `prune --source X --yes` meaning >90d). Any NEW attribute
# filter suppresses the implicit cutoff so e.g.
# `prune --model sonnet` matches ALL sessions for that model.
# Preserve the historical default ONLY for a truly bare
# `hermes sessions prune`: no time window and no filters at all
# means "older than 90 days". ANY filter — including --source —
# suppresses the implicit cutoff, so `prune --source cron`
# matches ALL cron sessions regardless of age. The preview +
# confirmation below (count, oldest/newest) is the safety net.
_non_time_filters = any(
getattr(args, a, None) is not None
for a in (
"title", "end_reason", "cwd",
"source", "title", "end_reason", "cwd",
"min_messages", "max_messages", "model", "provider",
"user", "chat_id", "chat_type", "branch",
"min_tokens", "max_tokens", "min_cost", "max_cost",
@ -13797,11 +13798,19 @@ def main():
print(f"No sessions match ({describe_filters(filters)}).")
return
# Candidates are ordered oldest-first — surface the age span so
# the confirmation makes the blast radius obvious.
_oldest = candidates[0].get("started_at")
_newest = candidates[-1].get("started_at")
_span = (
f"oldest {format_epoch(_oldest)}, newest {format_epoch(_newest)}"
)
if args.dry_run or not args.yes:
shown = candidates if args.dry_run else candidates[:15]
print(
f"{len(candidates)} session(s) match "
f"({describe_filters(filters)}):"
f"({describe_filters(filters)}; {_span}):"
)
for s in shown:
title = (s.get("title") or "")[:36]
@ -13819,7 +13828,7 @@ def main():
if not args.yes:
if not _confirm_prompt(
f"{verb} these {len(candidates)} session(s)? [y/N] "
f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "
):
print("Cancelled.")
return

View file

@ -8234,11 +8234,28 @@ async def prune_sessions_endpoint(body: SessionPrune):
)
if body.older_than_days is not None and body.older_than_days < 1 and not has_window:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
# Mirror the CLI: the implicit 90-day cutoff only applies to a truly bare
# prune. Any attribute filter (source, title, model, ...) suppresses it
# unless the caller explicitly sent older_than_days.
_attr_filters_set = any(
getattr(body, f) is not None
for f in (
"source", "title_like", "end_reason", "cwd_prefix",
"min_messages", "max_messages", "model_like", "provider",
"user_id", "chat_id", "chat_type", "branch_like",
"min_tokens", "max_tokens", "min_cost", "max_cost",
"min_tool_calls", "max_tool_calls",
)
)
_older_than_explicit = "older_than_days" in body.model_fields_set
_effective_older_than = body.older_than_days
if has_window or (_attr_filters_set and not _older_than_explicit):
_effective_older_than = None
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile)
try:
filters = dict(
older_than_days=None if has_window else body.older_than_days,
older_than_days=_effective_older_than,
source=(body.source or None),
started_before=body.started_before,
started_after=body.started_after,
@ -8267,6 +8284,9 @@ async def prune_sessions_endpoint(body: SessionPrune):
"ok": True,
"removed": 0,
"matched": len(rows),
# Rows are ordered oldest-first.
"oldest_started_at": rows[0]["started_at"] if rows else None,
"newest_started_at": rows[-1]["started_at"] if rows else None,
"sessions": [
{
"id": r["id"],

View file

@ -452,6 +452,36 @@ class TestSessionManagementEndpoints:
"/api/sessions/prune", json={"older_than_days": 0}
).status_code == 400
def test_prune_attr_filter_suppresses_default_cutoff(self):
# An attribute filter without an explicit older_than_days matches all
# ages (mirrors the CLI: any filter disables the implicit 90-day
# default). dry_run so nothing is deleted; the seeded session is
# recent + ended, so it would be invisible under a 90-day cutoff.
from hermes_state import SessionDB
db = SessionDB()
db.create_session(session_id="sess-recent-ended", source="cli")
db.end_session("sess-recent-ended", "completed")
db.close()
r = self.client.post(
"/api/sessions/prune",
json={"source": "cli", "dry_run": True},
)
assert r.status_code == 200
body = r.json()
assert body["matched"] >= 1
assert "oldest_started_at" in body and "newest_started_at" in body
def test_prune_explicit_older_than_kept_with_attr_filter(self):
# Explicit older_than_days is honored even alongside attribute filters.
r = self.client.post(
"/api/sessions/prune",
json={"source": "cli", "older_than_days": 9999, "dry_run": True},
)
assert r.status_code == 200
assert r.json()["matched"] == 0
class TestSkillsHubSearchEndpoint:
@pytest.fixture(autouse=True)

View file

@ -1,5 +1,7 @@
import sys
import pytest
def test_sessions_delete_accepts_unique_id_prefix(monkeypatch, capsys):
import hermes_cli.main as main_mod
@ -128,3 +130,93 @@ def test_sessions_prune_handles_eoferror_on_confirm(monkeypatch, capsys):
output = capsys.readouterr().out
assert "Cancelled" in output
def _run_prune(monkeypatch, capsys, argv_tail, candidates=None):
"""Run `hermes sessions prune <argv_tail>` against a FakeDB, capturing
the filter kwargs passed to list_prune_candidates. Auto-confirms."""
import hermes_cli.main as main_mod
import hermes_state
seen = {}
rows = candidates if candidates is not None else [
{
"id": "20260101_000000_aaaaaa",
"source": "cron",
"title": "oldest run",
"started_at": 1_600_000_000.0,
"ended_at": 1_600_000_100.0,
"message_count": 2,
"archived": 0,
},
{
"id": "20260601_000000_bbbbbb",
"source": "cron",
"title": "newest run",
"started_at": 1_700_000_000.0,
"ended_at": 1_700_000_100.0,
"message_count": 4,
"archived": 0,
},
]
class FakeDB:
def list_prune_candidates(self, **kwargs):
seen.update(kwargs)
return rows
def prune_sessions(self, **kwargs):
return len(rows)
def close(self):
pass
monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB())
monkeypatch.setattr(
sys, "argv", ["hermes", "sessions", "prune", *argv_tail]
)
monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
main_mod.main()
return seen, capsys.readouterr().out
def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys):
"""A truly bare `hermes sessions prune` keeps the implicit 90-day cutoff."""
import time as _time
filters, _out = _run_prune(monkeypatch, capsys, [])
assert filters["started_before"] is not None
assert filters["started_before"] == pytest.approx(
_time.time() - 90 * 86400, abs=60
)
def test_sessions_prune_source_matches_all_ages(monkeypatch, capsys):
"""--source alone suppresses the implicit 90-day cutoff (all ages)."""
filters, _out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
assert filters["started_before"] is None
assert filters["started_after"] is None
assert filters["source"] == "cron"
def test_sessions_prune_source_with_explicit_time_respected(monkeypatch, capsys):
"""--source + explicit --older-than keeps the user's bound."""
import time as _time
filters, _out = _run_prune(
monkeypatch, capsys, ["--source", "cron", "--older-than", "30"]
)
assert filters["started_before"] == pytest.approx(
_time.time() - 30 * 86400, abs=60
)
assert filters["source"] == "cron"
def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys):
"""Confirmation preview surfaces count + oldest/newest session times."""
from hermes_cli.session_filters import format_epoch
_filters, out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
assert "2 session(s) match" in out
assert f"oldest {format_epoch(1_600_000_000.0)}" in out
assert f"newest {format_epoch(1_700_000_000.0)}" in out

View file

@ -348,8 +348,10 @@ hermes sessions prune --newer-than 5h
# Explicit window with absolute timestamps
hermes sessions prune --after "2026-07-05 09:00" --before "2026-07-05 14:30"
# Only prune sessions from a specific platform
hermes sessions prune --source telegram --older-than 60
# Only prune sessions from a specific platform (all ages — any filter
# disables the implicit 90-day default)
hermes sessions prune --source telegram
hermes sessions prune --source cron --older-than 60 # add a time flag to narrow
# More filters — all AND together
hermes sessions prune --newer-than 5h --title "smoke test" # title substring
@ -380,10 +382,12 @@ Attribute filters: `--source` (platform, exact), `--title` / `--model` /
exact), `--end-reason`, `--user`, `--chat-id`, `--chat-type` (exact),
`--cwd` (path prefix), plus numeric bounds `--min/--max-messages`,
`--min/--max-tokens` (input+output), `--min/--max-cost` (USD, actual falling
back to estimated), and `--min/--max-tool-calls`. Using any attribute filter
(other than `--source`) disables the implicit 90-day default, so
`hermes sessions prune --model gpt-4o` matches all ages — add a time flag to
narrow it.
back to estimated), and `--min/--max-tool-calls`. Using any filter disables
the implicit 90-day default, so `hermes sessions prune --source cron` or
`--model gpt-4o` matches all ages — add a time flag to narrow it. Only a
completely bare `hermes sessions prune` keeps the 90-day cutoff. Every
non-`--yes` run shows the match count plus the oldest and newest matching
session before asking for confirmation.
Archived sessions are skipped by default; pass `--include-archived` to
delete them too.