# NousResearch/hermes-agent · Make curator manual runs synchronous to prevent data loss

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #21216. Verify it addresses your case.
- **Recommended:** read PR #21216 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-21216`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #20555: \[Bug\]: \`hermes curator run\` can lose LLM reports because CLI exits while background daemon thread is still running

## Prior & competing PRs

- **#21216: fix(curator): make manual 'hermes curator run' synchronous by default (#20555)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `hermes_cli/curator.py`

    ```diff
    @@ -12,6 +12,7 @@
     import argparse
     import sys
     from datetime import datetime, timezone
    +from pathlib import Path
     from typing import Optional
     
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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", {})
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#20556: fix(curator): make manual runs synchronous**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `6b9f7140b`, `#21216`

## Fix status

- Merged to main in PR #21216 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-07`.
