NousResearch/hermes-agent

Make curator manual runs synchronous to prevent data loss

Cluster 20555. Data as of

Nothing open on GitHub. Fix merged in #21216 on 7 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #21216. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #21216. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug was identified where 'hermes curator run' could lose LLM reports due to the CLI exiting before a background daemon thread finished. These members address making manual curator runs synchronous by default.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #21216 fix(curator): make manual 'hermes curator run' synchronous by default (#20555)

    merged to main

    files: hermes_cli/curator.py, tests/hermes_cli/test_curator_run.py, tests/hermes_cli/test_curator_status.py, website/docs/reference/cli-commands.md, website/docs/user-guide/features/curator.md

    Show the diff
    • hermes_cli/curator.py
      @@ -12,6 +12,7 @@
       import argparse
       import sys
       from datetime import datetime, timezone
      +from pathlib import Path
       from typing import Optional
       
       
      @@ -57,7 +58,8 @@ def _cmd_status(args) -> int:
           print(f"  last summary:   {summary}")
           _report = state.get("last_report_path")
           if _report:
      -        print(f"  last report:    {_report}")
      +        suffix = "" if Path(_report).exists() else " (missing)"
      +        print(f"  last report:    {_report}{suffix}")
           _ih = curator.get_interval_hours()
           _interval_label = (
               f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
      @@ -161,6 +163,8 @@ def _cmd_run(args) -> int:
               return 1
       
           dry = bool(getattr(args, "dry_run", False))
      +    background = bool(getattr(args, "background", False))
      +    synchronous = bool(getattr(args, "synchronous", False)) or not background
           if dry:
               print("curator: running DRY-RUN (report only, no mutations)...")
           else:
      @@ -171,7 +175,7 @@ def _on_summary(msg: str) -> None:
       
           result = curator.run_curator_review(
               on_summary=_on_summary,
      -        synchronous=bool(args.synchronous),
      +        synchronous=synchronous,
               dry_run=dry,
           )
           auto = result.get("auto_transitions", {})
      @@ -188,13 +192,19 @@ def _on_summary(msg: str) -> None:
                       f"archived={auto.get('archived', 0)} "
                       f"reactivated={auto.get('reactivated', 0)}"
                   )
      -    if not args.synchronous:
      +    if not synchronous:
               print("llm pass running in background — check `hermes curator status` later")
           if dry:
      -        print(
      -            "dry-run: no changes applied. When the report lands, read it with "
      -            "`hermes curator status` and run `hermes curator run` (no flag) to apply."
      -        )
      +        if synchronous:
      +            print(
      +                "dry-run: no changes applied. Read the report with "
      +                "`hermes curator status` and run `hermes curator run` (no flag) to apply."
      +            )
      +        else:
      +            print(
      +                "dry-run: no changes applied. When the report lands, read it with "
      +                "`hermes curator status` and run `hermes curator run` (no flag) to apply."
      +            )
           return 0
       
       
      @@ -461,7 +471,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
           p_run = subs.add_parser("run", help="Trigger a curator review now")
           p_run.add_argument(
               "--sync", "--synchronous", dest="synchronous", action="store_true",
      -        help="Wait for the LLM review pass to finish (default: background thread)",
      +        help="Wait for the LLM review pass to finish (default for manual runs)",
      +    )
      +    p_run.add_argument(
      +        "--background", dest="background", action="store_true",
      +        help="Start the LLM review pass in a background thread and return immediately",
           )
           p_run.add_argument(
               "--dry-run", dest="dry_run", action="store_true",
    • tests/hermes_cli/test_curator_run.py
      @@ -0,0 +1,87 @@
      +"""Tests for `hermes curator run` CLI behavior."""
      +
      +from __future__ import annotations
      +
      +from types import SimpleNamespace
      +
      +
      +def _args(**kwargs):
      +    values = {
      +        "dry_run": False,
      +        "synchronous": False,
      +        "background": False,
      +    }
      +    values.update(kwargs)
      +    return SimpleNamespace(**values)
      +
      +
      +def test_run_defaults_to_synchronous(monkeypatch, capsys):
      +    import agent.curator as curator_state
      +    import hermes_cli.curator as curator_cli
      +
      +    calls = []
      +    monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
      +    monkeypatch.setattr(
      +        curator_state,
      +        "run_curator_review",
      +        lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
      +    )
      +
      +    assert curator_cli._cmd_run(_args()) == 0
      +
      +    assert calls[0]["synchronous"] is True
      +    assert calls[0]["dry_run"] is False
      +    assert "background" not in capsys.readouterr().out
      +
      +
      +def test_run_background_opts_into_async(monkeypatch, capsys):
      +    import agent.curator as curator_state
      +    import hermes_cli.curator as curator_cli
      +
      +    calls = []
      +    monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
      +    monkeypatch.setattr(
      +        curator_state,
      +        "run_curator_review",
      +        lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
      +    )
      +
      +    assert curator_cli._cmd_run(_args(background=True)) == 0
      +
      +    assert calls[0]["synchronous"] is False
      +    assert "llm pass running in background" in capsys.readouterr().out
      +
      +
      +def test_run_sync_wins_over_background(monkeypatch):
      +    import agent.curator as curator_state
      +    import hermes_cli.curator as curator_cli
      +
      +    calls = []
      +    monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
      +    monkeypatch.setattr(
      +        curator_state,
      +        "run_curator_review",
      +        lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
      +    )
      +
      +    assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0
      +
      +    assert calls[0]["synchronous"] is True
      +
      +
      +def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys):
      +    import agent.curator as curator_state
      +    import hermes_cli.curator as curator_cli
      +
      +    monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
      +    monkeypatch.setattr(
      +        curator_state,
      +        "run_curator_review",
      +        lambda **kwargs: {"auto_transitions": {}},
      +    )
      +
      +    assert curator_cli._cmd_run(_args(dry_run=True)) == 0
      +
      +    out = capsys.readouterr().out
      +    assert "When the report lands" not in out
      +    assert "Read the report with `hermes curator status`" in out
    • tests/hermes_cli/test_curator_status.py
      @@ -175,3 +175,28 @@ def test_status_no_skills_produces_clean_empty_output(curator_status_env):
           # None of the ranking sections render
           assert "most active" not in out
           assert "least active" not in out
      +
      +
      +def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):
      +    import agent.curator as curator_state
      +    import hermes_cli.curator as curator_cli
      +    import tools.skill_usage as skill_usage
      +
      +    missing_report = tmp_path / "stale-report"
      +    monkeypatch.setattr(curator_state, "load_state", lambda: {
      +        "paused": False,
      +        "last_run_at": None,
      +        "last_run_summary": "auto: no changes",
      +        "run_count": 1,
      +        "last_report_path": str(missing_report),
      +    })
      +    monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
      +    monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
      +    monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
      +    monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
      +    monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
      +
      +    assert curator_cli._cmd_status(SimpleNamespace()) == 0
      +
      +    out = capsys.readouterr().out
      +    assert f"last report:    {missing_report} (missing)" in out
    • website/docs/reference/cli-commands.md
      @@ -802,8 +802,8 @@ The curator is an auxiliary-model background task that periodically reviews agen
       | Subcommand | Description |
       |------------|-------------|
       | `status` | Show curator status and skill stats |
      -| `run` | Trigger a curator review now |
      -| `run --sync` | Block until the LLM pass finishes |
      +| `run` | Trigger a curator review now (blocks until the LLM pass finishes) |
      +| `run --background` | Start the LLM pass in a background thread and return immediately |
       | `run --dry-run` | Preview only — produce the review report with no mutations |
       | `backup` | Take a manual tar.gz snapshot of `~/.hermes/skills/` (curator also snapshots automatically before every real run) |
       | `rollback` | Restore `~/.hermes/skills/` from a snapshot (defaults to newest) |
    • website/docs/user-guide/features/curator.md
      @@ -84,8 +84,8 @@ Earlier releases used a one-off `curator.auxiliary.{provider,model}` block. That
       
       ```bash
       hermes curator status         # last run, counts, pinned list, LRU top 5
      -hermes curator run            # trigger a review now (background by default)
      -hermes curator run --sync     # same, but block until the LLM pass finishes
      +hermes curator run            # trigger a review now (blocks until the LLM pass finishes)
      +hermes curator run --background  # fire-and-forget: start the LLM pass in a background thread
       hermes curator run --dry-run  # preview only — report without any mutations
       hermes curator backup         # take a manual snapshot of ~/.hermes/skills/
       hermes curator rollback       # restore from the newest snapshot

1 PR closed without merging

From the close comments: merged via another change 1

  • #20556 fix(curator): make manual runs synchronous · merged via another change → 6b9f714, #21216
Closed issues · 1
  • Closed#20555[Bug]: `hermes curator run` can lose LLM reports because CLI exits while background daemon thread is still running
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#21216
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
7 May 2026

For your agent

The same facts, typed.