fix(serve): run idle skill maintenance on the existing timer

Desktop-only backends now poll curator and personal/org skill sync without another long-lived loop. Respect active turns, the actual idle threshold, and messaging gateway ownership. Credit Jackal991 for the report and candidate #95453.
This commit is contained in:
Teknium
2026-09-07 00:54:08 -07:00
parent 04fedadbe9
commit 2f090fbdec
3 changed files with 95 additions and 4 deletions
+45 -3
View File
@@ -230,14 +230,56 @@ def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
_log.debug("opportunistic auto-archive skipped: %s", exc)
def _skill_maintenance_idle_for(started_at: float) -> Optional[float]:
"""Measure chat inactivity, not socket inactivity (Desktop stays connected)."""
import tui_gateway.server as gateway
with gateway._sessions_lock:
if any(session.get("running") for session in gateway._sessions.values()):
return None
last_active = max(
[started_at] + [float(session.get("last_active") or started_at)
for session in gateway._sessions.values()])
return max(0.0, time.time() - last_active)
def _maybe_run_skill_maintenance(started_at: float) -> None:
from hermes_constants import get_hermes_home
from hermes_cli.profiles import _check_gateway_running
# A live messaging gateway already owns these chores for this profile.
if _check_gateway_running(get_hermes_home()):
return
from agent.curator import maybe_run_curator
from tools.skills_sync_client import maybe_pull_skills
from tools.skills_sync_client_org import maybe_pull_org_skills
try:
idle_for = _skill_maintenance_idle_for(started_at)
if idle_for is not None:
maybe_run_curator(idle_for_seconds=idle_for)
except Exception as exc:
_log.debug("serve curator tick skipped: %s", exc)
for pull in (maybe_pull_skills, maybe_pull_org_skills):
try:
pull()
except Exception as exc:
_log.debug("serve skill sync tick skipped: %s", exc)
async def _auto_archive_ticker_loop(
interval_s: float = 3600.0, initial_delay_s: float = 90.0) -> None:
"""Poll-rate timer for the auto-archive sweep (primary profile), so a
long-idle Desktop keeps sweeping without any ``/api/sessions`` request.
The real cadence is still owned by state_meta inside ``maybe_auto_archive``."""
"""Poll maintenance for this serve profile, including Desktop-only installs.
Individual chores own their config/interval gates. Curator additionally uses
real chat inactivity; merely keeping a Desktop WebSocket open is not activity.
"""
started_at = time.time()
def _sweep() -> None:
_maybe_auto_archive_for_profile(None)
_maybe_run_skill_maintenance(started_at)
await asyncio.sleep(initial_delay_s)
while True:
@@ -0,0 +1,45 @@
"""Exercise the real periodic maintenance loop with isolated on-disk state."""
import asyncio
import json
from contextlib import suppress
import pytest
@pytest.mark.asyncio
async def test_serve_timer_runs_due_curator_once_and_honors_pause(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "skills").mkdir()
(tmp_path / "config.yaml").write_text(
"curator:\n enabled: true\n consolidate: false\n interval_hours: 168\n"
" min_idle_hours: 0\n prune_builtins: false\n", encoding="utf-8")
from agent.curator import load_state, save_state, set_paused
from hermes_cli.web_server_sessions import _auto_archive_ticker_loop
save_state({"last_run_at": "2020-01-01T00:00:00+00:00", "run_count": 0, "paused": True})
task = asyncio.create_task(_auto_archive_ticker_loop(interval_s=.02, initial_delay_s=0))
try:
await asyncio.sleep(.15)
assert load_state()["run_count"] == 0
set_paused(False)
# Active turns must suppress maintenance even with a zero idle threshold.
import tui_gateway.server as gateway
with gateway._sessions_lock:
gateway._sessions['maintenance-test'] = {"running": True}
try:
await asyncio.sleep(.15)
assert load_state()["run_count"] == 0
finally:
with gateway._sessions_lock:
gateway._sessions.pop('maintenance-test', None)
async with asyncio.timeout(8):
while load_state()["run_count"] == 0:
await asyncio.sleep(.02)
await asyncio.sleep(.15)
state = json.loads((tmp_path / "skills" / ".curator_state").read_text())
assert state["run_count"] == 1
assert "consolidation off" in state["last_run_summary"]
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
+5 -1
View File
@@ -16,11 +16,15 @@ Tracks [issue #7816](https://github.com/NousResearch/hermes-agent/issues/7816).
## How it runs
The curator is triggered by an inactivity check, not a cron daemon. On CLI session start, and on a recurring tick inside the gateway's cron-ticker thread, Hermes checks whether:
The curator is triggered by an inactivity check, not a cron job. On CLI session start, during gateway housekeeping, and on the Desktop/`hermes serve` maintenance timer, Hermes checks whether:
1. Enough time has passed since the last curator run (`interval_hours`, default **7 days**), and
2. The agent has been idle long enough (`min_idle_hours`, default **2 hours**).
Desktop and other `hermes serve` backends share the existing hourly maintenance timer (first poll after 90 seconds), independently of cron jobs. They measure inactivity from process startup and the most recent chat activity, and skip curator while a turn is running. A connected but inactive window does not block maintenance. This timer also polls personal and organization Skill Sync, subject to those features' own opt-in gates. A running messaging gateway for the same profile owns these chores instead.
The timer services its backend's profile. In-flight maintenance runs in a worker thread; closing the backend does not cooperatively interrupt that pass. Starting multiple independent serve processes for the same profile can still race the curator's interval check.
If both are true, it spawns a background fork of `AIAgent` — the same pattern used by the memory/skill self-improvement nudges. The fork runs in its own prompt cache and never touches the active conversation.
:::info First-run behavior