diff --git a/cli.py b/cli.py index 9d3b6fc4cf5b..4d6ed383affa 100644 --- a/cli.py +++ b/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( diff --git a/gateway/run.py b/gateway/run.py index ec833421ec08..4272799c867e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f56c35fcad16..6e4d4d0b8a45 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d6c08510f50b..aa034835dda5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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() diff --git a/hermes_state.py b/hermes_state.py index 4158a911ea2f..468d9adf8b2c 100644 --- a/hermes_state.py +++ b/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: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 314040e088da..9c6299a65417 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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).""" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 947973d3b6ee..65dd4299a201 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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."""