feat(sessions): opt-in auto-archive of stale sessions + durable pin flag

New sessions.auto_archive / auto_archive_days config: soft-hide (never
delete) sessions with no activity for N days, aging on last activity
rather than creation so an old-but-active chat is spared. Sweeps are
throttled through state_meta and fire from CLI startup, gateway startup
+ hourly housekeeping, and the serve/dashboard backend (opportunistic
on session list + an hourly lifespan ticker), so every surface honours
one setting.

A new pinned column (declaratively migrated) exempts sessions from the
sweep; PATCH /api/sessions/{id} accepts pinned and flips the whole
compression lineage as a unit, mirroring set_session_archived.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 10:17:40 -05:00
parent c416d9ae0a
commit f16b80362c
7 changed files with 499 additions and 5 deletions

View file

@ -223,11 +223,16 @@ async def _lifespan(app: "FastAPI"):
# /api/status). The loop exits immediately when httpx is unavailable.
selftest_task = asyncio.create_task(_dashboard_selftest_loop())
# Live auto-archive timer — keeps a backend that stays up for days
# sweeping stale sessions on schedule, independent of list requests.
auto_archive_task = asyncio.create_task(_auto_archive_ticker_loop())
try:
yield
finally:
pty_reaper_task.cancel()
selftest_task.cancel()
auto_archive_task.cancel()
await PTY_REGISTRY.close_all()
if cron_stop is not None:
cron_stop.set()
@ -4776,6 +4781,10 @@ def get_sessions(
try:
db = _open_session_db_for_profile(profile)
try:
# Opportunistic, config-gated, double-throttled stale-session
# sweep — the only auto_archive hook that fires for Desktop's
# `hermes serve` backend. No-op when disabled or run recently.
_maybe_auto_archive_for_profile(db, profile)
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
@ -11446,6 +11455,71 @@ def _open_session_db_for_profile(profile: Optional[str]):
return SessionDB(db_path=Path(home) / "state.db")
# In-process throttle for the opportunistic auto-archive trigger, keyed by
# profile. Bounds the config.yaml read to at most once per this window per
# profile; the actual sweep is throttled far more coarsely by state_meta
# (sessions.min_interval_hours) inside maybe_auto_archive.
_AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0
_last_auto_archive_check: Dict[str, float] = {}
def _maybe_auto_archive_for_profile(db, profile: Optional[str]) -> None:
"""Run the config-gated stale-session auto-archive for ``profile``.
The Desktop backend is spawned as ``hermes serve`` it runs neither the
interactive CLI nor the messaging gateway, so neither of those startup
hooks fire for Desktop users. Triggering the (double-throttled, config-off
by default) sweep from the session-list path is what makes
``sessions.auto_archive`` take effect there. Never raises.
"""
try:
key = profile or ""
now = time.monotonic()
last = _last_auto_archive_check.get(key)
if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S:
return
_last_auto_archive_check[key] = now
from hermes_cli.config import load_config as _load_full_config
cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_archive", False):
return
db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
except Exception as exc:
_log.debug("opportunistic auto-archive skipped: %s", exc)
async def _auto_archive_ticker_loop(
interval_s: float = 3600.0, initial_delay_s: float = 90.0
) -> None:
"""Live timer for the stale-session auto-archive (primary profile).
A long-running Desktop/serve backend must keep sweeping on schedule even
when no ``/api/sessions`` request arrives to fire the opportunistic
trigger e.g. the app sits open for days on an idle chat. The real
cadence is still owned by state_meta (``sessions.min_interval_hours``)
inside ``maybe_auto_archive``; this loop is only the poll rate.
"""
def _sweep() -> None:
db = _open_session_db_for_profile(None)
try:
_maybe_auto_archive_for_profile(db, None)
finally:
db.close()
await asyncio.sleep(initial_delay_s)
while True:
try:
await asyncio.to_thread(_sweep)
except Exception as exc:
_log.debug("auto-archive tick skipped: %s", exc)
await asyncio.sleep(interval_s)
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
@ -11550,6 +11624,9 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
# Durable "keep" flag mirrored from the Desktop sidebar's pins; pinned
# sessions are exempt from the sessions.auto_archive stale sweep.
pinned: Optional[bool] = None
# Mutate a session belonging to another profile (opens its state.db). Omit
# for the current/default profile.
profile: Optional[str] = None
@ -11557,21 +11634,22 @@ class SessionRename(BaseModel):
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title) and/or archive it.
"""Update a session: rename, archive, and/or pin it.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session. Either field may be omitted. ``profile`` targets
another profile's session.
restores the session; ``pinned`` sets the durable keep flag (exempts the
session from the auto-archive sweep). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and body.archived is None:
if body.title is None and body.archived is None and body.pinned is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title' and/or 'archived'.",
detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.",
)
if body.title is not None:
try:
@ -11581,9 +11659,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.pinned is not None:
db.set_session_pinned(sid, body.pinned)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.pinned is not None:
result["pinned"] = bool(body.pinned)
return result
finally:
db.close()