mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
c416d9ae0a
commit
f16b80362c
7 changed files with 499 additions and 5 deletions
10
cli.py
10
cli.py
|
|
@ -1972,6 +1972,16 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
|||
logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
|
||||
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
|
||||
# Auto-archive (soft-hide stale sessions) is independent of the
|
||||
# destructive auto_prune sweep — run it first, before prune's early
|
||||
# return, so enabling one doesn't require the other.
|
||||
if cfg.get("auto_archive", False):
|
||||
session_db.maybe_auto_archive(
|
||||
idle_days=float(cfg.get("auto_archive_days", 3)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
)
|
||||
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
session_db.maybe_auto_prune_and_vacuum(
|
||||
|
|
|
|||
|
|
@ -3598,6 +3598,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
# Non-destructive stale-session archive, independent of prune.
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
self._session_db._db.maybe_auto_archive(
|
||||
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
)
|
||||
if _sess_cfg.get("auto_prune", False):
|
||||
# Construction-time, before the loop serves traffic; sync DB is fine.
|
||||
self._session_db._db.maybe_auto_prune_and_vacuum(
|
||||
|
|
@ -23360,6 +23366,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
|
|||
CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes
|
||||
PASTE_SWEEP_EVERY = 60 # ticks — once per hour
|
||||
CURATOR_EVERY = 60 # ticks — poll hourly (inner gate handles the real cadence)
|
||||
AUTO_ARCHIVE_EVERY = 60 # ticks — poll hourly (state_meta gate owns the real cadence)
|
||||
|
||||
logger.info("Gateway housekeeping started (interval=%ds)", interval)
|
||||
tick_count = 0
|
||||
|
|
@ -23424,6 +23431,28 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
|
|||
except Exception as e:
|
||||
logger.debug("Curator tick error: %s", e)
|
||||
|
||||
# Stale-session auto-archive — a live timer, so gateways that stay up
|
||||
# for weeks keep sweeping on schedule (the startup hook fires once).
|
||||
# maybe_auto_archive() is gated by sessions.min_interval_hours in
|
||||
# state_meta; this is just the poll rate. Opens its own SessionDB —
|
||||
# SQLite connections are thread-bound and this runs off-loop.
|
||||
if tick_count % AUTO_ARCHIVE_EVERY == 0:
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_state import SessionDB
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
_adb = SessionDB()
|
||||
try:
|
||||
_adb.maybe_auto_archive(
|
||||
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
)
|
||||
finally:
|
||||
_adb.close()
|
||||
except Exception as e:
|
||||
logger.debug("Auto-archive tick error: %s", e)
|
||||
|
||||
stop_event.wait(timeout=interval)
|
||||
logger.info("Gateway housekeeping stopped")
|
||||
|
||||
|
|
|
|||
|
|
@ -3226,6 +3226,15 @@ DEFAULT_CONFIG = {
|
|||
# How many days of ended-session history to keep. Matches the
|
||||
# default of ``hermes sessions prune``.
|
||||
"retention_days": 90,
|
||||
# When true, auto-archive (soft-hide, never delete) sessions that
|
||||
# haven't been touched in ``auto_archive_days`` days, once per
|
||||
# (roughly) min_interval_hours. "Touched" is last activity, not
|
||||
# creation, so an old-but-recently-used session is spared. Pinned
|
||||
# sessions are always exempt. Off by default — opt in explicitly.
|
||||
"auto_archive": False,
|
||||
# Idle threshold (days of no activity) before auto-archive hides a
|
||||
# session. Only applies when auto_archive is true.
|
||||
"auto_archive_days": 3,
|
||||
# VACUUM after a prune that actually deleted rows. SQLite does not
|
||||
# reclaim disk space on DELETE — freed pages are just reused on
|
||||
# subsequent INSERTs — so without VACUUM the file stays bloated
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
155
hermes_state.py
155
hermes_state.py
|
|
@ -1101,6 +1101,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||
profile_name TEXT,
|
||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
|
|
@ -5074,6 +5075,58 @@ class SessionDB:
|
|||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
|
||||
"""Pin or unpin a session (and its whole compression lineage).
|
||||
|
||||
``pinned`` is a durable "keep" flag: pinned sessions are exempt from
|
||||
the ``sessions.auto_archive`` stale sweep (see
|
||||
:meth:`archive_stale_sessions`). Desktop is the current writer — its
|
||||
sidebar pins mirror here so a backend/other-surface sweep honours
|
||||
them. Like :meth:`set_session_archived` the whole compression chain is
|
||||
flipped as a unit, so pinning the surfaced tip protects the root (and
|
||||
vice-versa) no matter which id the caller holds. Returns True when at
|
||||
least one row changed.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
WITH RECURSIVE
|
||||
ancestors(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT parent.id
|
||||
FROM ancestors a
|
||||
JOIN sessions child ON child.id = a.id
|
||||
JOIN sessions parent ON parent.id = child.parent_session_id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
),
|
||||
descendants(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM descendants d
|
||||
JOIN sessions parent ON parent.id = d.id
|
||||
JOIN sessions child ON child.parent_session_id = parent.id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
),
|
||||
lineage(id) AS (
|
||||
SELECT id FROM ancestors
|
||||
UNION
|
||||
SELECT id FROM descendants
|
||||
)
|
||||
UPDATE sessions
|
||||
SET pinned = ?
|
||||
WHERE id IN (SELECT id FROM lineage)
|
||||
""",
|
||||
(session_id, session_id, 1 if pinned else 0),
|
||||
)
|
||||
rowcount = cursor.rowcount
|
||||
if rowcount is None or rowcount < 0:
|
||||
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
||||
return rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a session by exact title. Returns session dict or None."""
|
||||
with self._lock:
|
||||
|
|
@ -9032,6 +9085,55 @@ class SessionDB:
|
|||
self.set_session_archived(row["id"], True)
|
||||
return len(rows)
|
||||
|
||||
def archive_stale_sessions(
|
||||
self, idle_days: float, *, exclude_pinned: bool = True
|
||||
) -> int:
|
||||
"""Archive every session untouched for at least ``idle_days`` days.
|
||||
|
||||
"Touched" is the latest message timestamp (falling back to
|
||||
``started_at``) — i.e. real recency, not creation time — so a session
|
||||
created long ago but active yesterday is spared, while an old
|
||||
abandoned one (even a still-open one) is swept. This differs from
|
||||
:meth:`archive_sessions`, which ages on ``started_at`` and only ended
|
||||
sessions.
|
||||
|
||||
Guards:
|
||||
* ``pinned = 0`` when ``exclude_pinned`` (the Desktop "keep" flag).
|
||||
* ``archived = 0`` so repeat runs are idempotent no-ops.
|
||||
* only lineage *tips* / standalone rows are candidates
|
||||
(``end_reason <> 'compression'``); a stale tip archives its whole
|
||||
chain via :meth:`set_session_archived`, so we never resurrect an
|
||||
active conversation by matching an old compressed-away root whose
|
||||
live continuation is recent.
|
||||
|
||||
Returns the number of sessions archived. Never raises for an empty or
|
||||
non-positive ``idle_days`` — it simply archives nothing.
|
||||
"""
|
||||
if idle_days is None or idle_days < 0:
|
||||
return 0
|
||||
cutoff = time.time() - float(idle_days) * 86400.0
|
||||
pin_clause = "AND s.pinned = 0" if exclude_pinned else ""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"""
|
||||
SELECT s.id FROM sessions s
|
||||
WHERE s.archived = 0
|
||||
AND COALESCE(s.end_reason, '') <> 'compression'
|
||||
{pin_clause}
|
||||
AND COALESCE(
|
||||
(SELECT MAX(m.timestamp) FROM messages m
|
||||
WHERE m.session_id = s.id),
|
||||
s.started_at
|
||||
) < ?
|
||||
ORDER BY s.started_at ASC
|
||||
""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
ids = [(r["id"] if isinstance(r, sqlite3.Row) else r[0]) for r in rows]
|
||||
for sid in ids:
|
||||
self.set_session_archived(sid, True)
|
||||
return len(ids)
|
||||
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: Optional[float] = 90,
|
||||
|
|
@ -9849,6 +9951,59 @@ class SessionDB:
|
|||
|
||||
return result
|
||||
|
||||
def maybe_auto_archive(
|
||||
self,
|
||||
idle_days: float = 3,
|
||||
min_interval_hours: int = 24,
|
||||
exclude_pinned: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent auto-archive: soft-hide sessions idle for ``idle_days``.
|
||||
|
||||
Sibling of :meth:`maybe_auto_prune_and_vacuum` but non-destructive —
|
||||
it archives (hides) rather than deletes, and ages on last activity
|
||||
(see :meth:`archive_stale_sessions`) rather than creation. Records the
|
||||
last run in ``state_meta['last_auto_archive']`` so calls within
|
||||
``min_interval_hours`` no-op; safe to call opportunistically (startup
|
||||
hooks, or when the Desktop backend lists sessions).
|
||||
|
||||
Never raises. Returns a dict with:
|
||||
- ``"skipped"`` (bool) — within min_interval_hours of last run
|
||||
- ``"archived"`` (int) — sessions archived this run
|
||||
- ``"error"`` (str, optional) — present only on failure
|
||||
"""
|
||||
result: Dict[str, Any] = {"skipped": False, "archived": 0}
|
||||
try:
|
||||
last_raw = self.get_meta("last_auto_archive")
|
||||
now = time.time()
|
||||
if last_raw:
|
||||
try:
|
||||
if now - float(last_raw) < min_interval_hours * 3600:
|
||||
result["skipped"] = True
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass # corrupt meta; treat as no prior run
|
||||
|
||||
archived = self.archive_stale_sessions(
|
||||
idle_days, exclude_pinned=exclude_pinned
|
||||
)
|
||||
result["archived"] = archived
|
||||
|
||||
# Record even a zero-archive run so we don't re-sweep every call
|
||||
# within the interval window.
|
||||
self.set_meta("last_auto_archive", str(now))
|
||||
|
||||
if archived > 0:
|
||||
logger.info(
|
||||
"state.db auto-archive: archived %d session(s) idle >= %s days",
|
||||
archived,
|
||||
idle_days,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("state.db auto-archive failed: %s", exc)
|
||||
result["error"] = str(exc)
|
||||
|
||||
return result
|
||||
|
||||
# ── Handoff (cross-platform session transfer) ──────────────────────────
|
||||
#
|
||||
# State machine:
|
||||
|
|
|
|||
|
|
@ -1878,6 +1878,88 @@ class TestWebServerEndpoints:
|
|||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_session_pins_and_exempts_from_auto_archive(self):
|
||||
"""PATCH pinned=true sets the keep flag; a pinned stale session is
|
||||
spared by the auto-archive sweep while an unpinned one is hidden."""
|
||||
import time as _time
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
old = _time.time() - 30 * 86400
|
||||
db = SessionDB()
|
||||
try:
|
||||
for sid in ("keep-me", "drop-me"):
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
db.append_message(session_id=sid, role="user", content="hi")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (old, sid)
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (old, sid)
|
||||
)
|
||||
db._conn.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/keep-me", json={"pinned": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pinned"] is True
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
archived = db.archive_stale_sessions(3)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert archived == 1
|
||||
listed = self.client.get("/api/sessions").json()["sessions"]
|
||||
ids = {s["id"] for s in listed}
|
||||
assert "keep-me" in ids # pinned -> spared
|
||||
assert "drop-me" not in ids # unpinned + stale -> archived
|
||||
|
||||
def test_list_triggers_config_gated_auto_archive(self):
|
||||
"""With sessions.auto_archive on, listing sessions opportunistically
|
||||
sweeps stale ones (the Desktop `hermes serve` code path)."""
|
||||
import time as _time
|
||||
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_state import SessionDB
|
||||
|
||||
old = _time.time() - 30 * 86400
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="stale-serve", source="cli")
|
||||
db.append_message(session_id="stale-serve", role="user", content="hi")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (old, "stale-serve")
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?",
|
||||
(old, "stale-serve"),
|
||||
)
|
||||
db._conn.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Reset the in-process throttle so the trigger actually evaluates config.
|
||||
ws._last_auto_archive_check.clear()
|
||||
|
||||
# The helper imports load_config lazily from hermes_cli.config; patch there.
|
||||
import hermes_cli.config as _config_mod
|
||||
|
||||
def _cfg():
|
||||
return {"sessions": {"auto_archive": True, "auto_archive_days": 3, "min_interval_hours": 0}}
|
||||
|
||||
prev = _config_mod.load_config
|
||||
_config_mod.load_config = _cfg # type: ignore[assignment]
|
||||
try:
|
||||
listed = self.client.get("/api/sessions").json()["sessions"]
|
||||
finally:
|
||||
_config_mod.load_config = prev # type: ignore[assignment]
|
||||
ws._last_auto_archive_check.clear()
|
||||
|
||||
assert all(s["id"] != "stale-serve" for s in listed)
|
||||
|
||||
def test_profiles_sessions_tags_default_profile(self):
|
||||
"""The cross-profile aggregator returns the default profile's rows
|
||||
tagged profile="default" (single-profile parity with /api/sessions)."""
|
||||
|
|
|
|||
|
|
@ -6488,6 +6488,133 @@ class TestSessionArchive:
|
|||
assert db.session_count(include_archived=True) == 2
|
||||
|
||||
|
||||
class TestSessionPinAndStaleArchive:
|
||||
"""Pin as a durable keep flag + last-activity-based stale auto-archive."""
|
||||
|
||||
def _pinned(self, db, sid):
|
||||
row = db._conn.execute(
|
||||
"SELECT pinned FROM sessions WHERE id = ?", (sid,)
|
||||
).fetchone()
|
||||
return row["pinned"] if row is not None else None
|
||||
|
||||
def _make_idle(self, db, sid, *, days_idle, source="cli"):
|
||||
"""A session whose latest activity was ``days_idle`` days ago."""
|
||||
db.create_session(session_id=sid, source=source)
|
||||
db.append_message(session_id=sid, role="user", content=f"msg {sid}")
|
||||
old = time.time() - days_idle * 86400
|
||||
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old, sid))
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (old, sid)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# ── pin flag ──────────────────────────────────────────────────────────
|
||||
def test_set_session_pinned_roundtrip(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
assert db.set_session_pinned("s1", True) is True
|
||||
assert self._pinned(db, "s1") == 1
|
||||
assert db.set_session_pinned("s1", False) is True
|
||||
assert self._pinned(db, "s1") == 0
|
||||
|
||||
def test_set_session_pinned_missing_row(self, db):
|
||||
assert db.set_session_pinned("nope", True) is False
|
||||
|
||||
def test_pin_propagates_across_compression_lineage(self, db):
|
||||
db.create_session(session_id="root", source="cli")
|
||||
db.end_session("root", end_reason="compression")
|
||||
db.create_session(session_id="tip", source="cli", parent_session_id="root")
|
||||
|
||||
# Pinning the surfaced tip pins the compressed-away root too.
|
||||
assert db.set_session_pinned("tip", True) is True
|
||||
assert self._pinned(db, "root") == 1
|
||||
assert self._pinned(db, "tip") == 1
|
||||
|
||||
# ── stale archive ─────────────────────────────────────────────────────
|
||||
def test_archives_only_sessions_idle_past_threshold(self, db):
|
||||
self._make_idle(db, "stale", days_idle=5)
|
||||
self._make_idle(db, "fresh", days_idle=1)
|
||||
|
||||
assert db.archive_stale_sessions(3) == 1
|
||||
assert db.get_session("stale")["archived"] == 1
|
||||
assert db.get_session("fresh")["archived"] == 0
|
||||
|
||||
def test_recency_uses_last_activity_not_creation(self, db):
|
||||
# Created long ago, but a message landed today -> not stale.
|
||||
db.create_session(session_id="old_but_active", source="cli")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - 60 * 86400, "old_but_active"),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.append_message(
|
||||
session_id="old_but_active", role="user", content="fresh activity"
|
||||
)
|
||||
|
||||
assert db.archive_stale_sessions(3) == 0
|
||||
assert db.get_session("old_but_active")["archived"] == 0
|
||||
|
||||
def test_pinned_sessions_are_spared(self, db):
|
||||
self._make_idle(db, "keep", days_idle=10)
|
||||
db.set_session_pinned("keep", True)
|
||||
|
||||
assert db.archive_stale_sessions(3) == 0
|
||||
assert db.get_session("keep")["archived"] == 0
|
||||
# Opting out of the pin guard sweeps it.
|
||||
assert db.archive_stale_sessions(3, exclude_pinned=False) == 1
|
||||
assert db.get_session("keep")["archived"] == 1
|
||||
|
||||
def test_already_archived_is_idempotent(self, db):
|
||||
self._make_idle(db, "stale", days_idle=5)
|
||||
assert db.archive_stale_sessions(3) == 1
|
||||
assert db.archive_stale_sessions(3) == 0
|
||||
|
||||
def test_stale_tip_archives_whole_compression_chain(self, db):
|
||||
# A compressed-away root is never a candidate on its own (its live
|
||||
# continuation may be fresh); the tip drives the decision and archives
|
||||
# the chain as a unit.
|
||||
db.create_session(session_id="root", source="cli")
|
||||
db.end_session("root", end_reason="compression")
|
||||
db.create_session(session_id="tip", source="cli", parent_session_id="root")
|
||||
old = time.time() - 9 * 86400
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id IN ('root', 'tip')", (old,)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert db.archive_stale_sessions(3) == 1 # one candidate (the tip)
|
||||
assert db.get_session("root")["archived"] == 1
|
||||
assert db.get_session("tip")["archived"] == 1
|
||||
|
||||
def test_non_positive_threshold_archives_nothing(self, db):
|
||||
self._make_idle(db, "stale", days_idle=100)
|
||||
assert db.archive_stale_sessions(-1) == 0
|
||||
assert db.get_session("stale")["archived"] == 0
|
||||
|
||||
# ── throttled wrapper ─────────────────────────────────────────────────
|
||||
def test_maybe_auto_archive_runs_then_throttles(self, db):
|
||||
self._make_idle(db, "stale", days_idle=5)
|
||||
first = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
|
||||
assert first["skipped"] is False
|
||||
assert first["archived"] == 1
|
||||
assert db.get_meta("last_auto_archive") is not None
|
||||
|
||||
# A newly-stale session within the interval is left untouched.
|
||||
self._make_idle(db, "stale2", days_idle=5)
|
||||
second = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
|
||||
assert second["skipped"] is True
|
||||
assert second["archived"] == 0
|
||||
assert db.get_session("stale2")["archived"] == 0
|
||||
|
||||
def test_maybe_auto_archive_reruns_after_interval(self, db):
|
||||
self._make_idle(db, "stale", days_idle=5)
|
||||
db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
|
||||
db.set_meta("last_auto_archive", str(time.time() - 48 * 3600))
|
||||
|
||||
self._make_idle(db, "stale2", days_idle=5)
|
||||
result = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
|
||||
assert result["skipped"] is False
|
||||
assert result["archived"] == 1
|
||||
|
||||
|
||||
class TestSessionIdSearch:
|
||||
"""Session id search backs Desktop's Search Sessions UX."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue