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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user