{"repo":"NousResearch/hermes-agent","public_id":"20555","display_label":"Make curator manual runs synchronous to prevent data loss","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21216,"merged_at":"2026-05-07T12:27:48+00:00"},"resolution":{"state":"resolved","by":"pr-21216"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:27:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":20555,"pr_number":21216,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:27:48+00:00"}],"members":[{"kind":"issue","number":20555,"title":"[Bug]: `hermes curator run` can lose LLM reports because CLI exits while background daemon thread is still running","url":null,"state":"closed"},{"kind":"pr","number":20556,"title":"fix(curator): make manual runs synchronous","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6b9f7140b","number":0},{"kind":"pr","value":"#21216","number":21216}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21216,"title":"fix(curator): make manual 'hermes curator run' synchronous by default (#20555)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"hermes_cli/curator.py","hunks":["@@ -12,6 +12,7 @@\n import argparse\n import sys\n from datetime import datetime, timezone\n+from pathlib import Path\n from typing import Optional\n \n ","@@ -57,7 +58,8 @@ def _cmd_status(args) -> int:\n     print(f\"  last summary:   {summary}\")\n     _report = state.get(\"last_report_path\")\n     if _report:\n-        print(f\"  last report:    {_report}\")\n+        suffix = \"\" if Path(_report).exists() else \" (missing)\"\n+        print(f\"  last report:    {_report}{suffix}\")\n     _ih = curator.get_interval_hours()\n     _interval_label = (\n         f\"{_ih // 24}d\" if _ih % 24 == 0 and _ih >= 24","@@ -161,6 +163,8 @@ def _cmd_run(args) -> int:\n         return 1\n \n     dry = bool(getattr(args, \"dry_run\", False))\n+    background = bool(getattr(args, \"background\", False))\n+    synchronous = bool(getattr(args, \"synchronous\", False)) or not background\n     if dry:\n         print(\"curator: running DRY-RUN (report only, no mutations)...\")\n     else:","@@ -171,7 +175,7 @@ def _on_summary(msg: str) -> None:\n \n     result = curator.run_curator_review(\n         on_summary=_on_summary,\n-        synchronous=bool(args.synchronous),\n+        synchronous=synchronous,\n         dry_run=dry,\n     )\n     auto = result.get(\"auto_transitions\", {})","@@ -188,13 +192,19 @@ def _on_summary(msg: str) -> None:\n                 f\"archived={auto.get('archived', 0)} \"\n                 f\"reactivated={auto.get('reactivated', 0)}\"\n             )\n-    if not args.synchronous:\n+    if not synchronous:\n         print(\"llm pass running in background — check `hermes curator status` later\")\n     if dry:\n-        print(\n-            \"dry-run: no changes applied. When the report lands, read it with \"\n-            \"`hermes curator status` and run `hermes curator run` (no flag) to apply.\"\n-        )\n+        if synchronous:\n+            print(\n+                \"dry-run: no changes applied. Read the report with \"\n+                \"`hermes curator status` and run `hermes curator run` (no flag) to apply.\"\n+            )\n+        else:\n+            print(\n+                \"dry-run: no changes applied. When the report lands, read it with \"\n+                \"`hermes curator status` and run `hermes curator run` (no flag) to apply.\"\n+            )\n     return 0\n \n ","@@ -461,7 +471,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n     p_run = subs.add_parser(\"run\", help=\"Trigger a curator review now\")\n     p_run.add_argument(\n         \"--sync\", \"--synchronous\", dest=\"synchronous\", action=\"store_true\",\n-        help=\"Wait for the LLM review pass to finish (default: background thread)\",\n+        help=\"Wait for the LLM review pass to finish (default for manual runs)\",\n+    )\n+    p_run.add_argument(\n+        \"--background\", dest=\"background\", action=\"store_true\",\n+        help=\"Start the LLM review pass in a background thread and return immediately\",\n     )\n     p_run.add_argument(\n         \"--dry-run\", dest=\"dry_run\", action=\"store_true\","]},{"file":"tests/hermes_cli/test_curator_run.py","hunks":["@@ -0,0 +1,87 @@\n+\"\"\"Tests for `hermes curator run` CLI behavior.\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+\n+\n+def _args(**kwargs):\n+    values = {\n+        \"dry_run\": False,\n+        \"synchronous\": False,\n+        \"background\": False,\n+    }\n+    values.update(kwargs)\n+    return SimpleNamespace(**values)\n+\n+\n+def test_run_defaults_to_synchronous(monkeypatch, capsys):\n+    import agent.curator as curator_state\n+    import hermes_cli.curator as curator_cli\n+\n+    calls = []\n+    monkeypatch.setattr(curator_state, \"is_enabled\", lambda: True)\n+    monkeypatch.setattr(\n+        curator_state,\n+        \"run_curator_review\",\n+        lambda **kwargs: calls.append(kwargs) or {\"auto_transitions\": {}},\n+    )\n+\n+    assert curator_cli._cmd_run(_args()) == 0\n+\n+    assert calls[0][\"synchronous\"] is True\n+    assert calls[0][\"dry_run\"] is False\n+    assert \"background\" not in capsys.readouterr().out\n+\n+\n+def test_run_background_opts_into_async(monkeypatch, capsys):\n+    import agent.curator as curator_state\n+    import hermes_cli.curator as curator_cli\n+\n+    calls = []\n+    monkeypatch.setattr(curator_state, \"is_enabled\", lambda: True)\n+    monkeypatch.setattr(\n+        curator_state,\n+        \"run_curator_review\",\n+        lambda **kwargs: calls.append(kwargs) or {\"auto_transitions\": {}},\n+    )\n+\n+    assert curator_cli._cmd_run(_args(background=True)) == 0\n+\n+    assert calls[0][\"synchronous\"] is False\n+    assert \"llm pass running in background\" in capsys.readouterr().out\n+\n+\n+def test_run_sync_wins_over_background(monkeypatch):\n+    import agent.curator as curator_state\n+    import hermes_cli.curator as curator_cli\n+\n+    calls = []\n+    monkeypatch.setattr(curator_state, \"is_enabled\", lambda: True)\n+    monkeypatch.setattr(\n+        curator_state,\n+        \"run_curator_review\",\n+        lambda **kwargs: calls.append(kwargs) or {\"auto_transitions\": {}},\n+    )\n+\n+    assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0\n+\n+    assert calls[0][\"synchronous\"] is True\n+\n+\n+def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys):\n+    import agent.curator as curator_state\n+    import hermes_cli.curator as curator_cli\n+\n+    monkeypatch.setattr(curator_state, \"is_enabled\", lambda: True)\n+    monkeypatch.setattr(\n+        curator_state,\n+        \"run_curator_review\",\n+        lambda **kwargs: {\"auto_transitions\": {}},\n+    )\n+\n+    assert curator_cli._cmd_run(_args(dry_run=True)) == 0\n+\n+    out = capsys.readouterr().out\n+    assert \"When the report lands\" not in out\n+    assert \"Read the report with `hermes curator status`\" in out"]},{"file":"tests/hermes_cli/test_curator_status.py","hunks":["@@ -175,3 +175,28 @@ def test_status_no_skills_produces_clean_empty_output(curator_status_env):\n     # None of the ranking sections render\n     assert \"most active\" not in out\n     assert \"least active\" not in out\n+\n+\n+def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):\n+    import agent.curator as curator_state\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    missing_report = tmp_path / \"stale-report\"\n+    monkeypatch.setattr(curator_state, \"load_state\", lambda: {\n+        \"paused\": False,\n+        \"last_run_at\": None,\n+        \"last_run_summary\": \"auto: no changes\",\n+        \"run_count\": 1,\n+        \"last_report_path\": str(missing_report),\n+    })\n+    monkeypatch.setattr(curator_state, \"is_enabled\", lambda: True)\n+    monkeypatch.setattr(curator_state, \"get_interval_hours\", lambda: 168)\n+    monkeypatch.setattr(curator_state, \"get_stale_after_days\", lambda: 30)\n+    monkeypatch.setattr(curator_state, \"get_archive_after_days\", lambda: 90)\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: [])\n+\n+    assert curator_cli._cmd_status(SimpleNamespace()) == 0\n+\n+    out = capsys.readouterr().out\n+    assert f\"last report:    {missing_report} (missing)\" in out"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -802,8 +802,8 @@ The curator is an auxiliary-model background task that periodically reviews agen\n | Subcommand | Description |\n |------------|-------------|\n | `status` | Show curator status and skill stats |\n-| `run` | Trigger a curator review now |\n-| `run --sync` | Block until the LLM pass finishes |\n+| `run` | Trigger a curator review now (blocks until the LLM pass finishes) |\n+| `run --background` | Start the LLM pass in a background thread and return immediately |\n | `run --dry-run` | Preview only — produce the review report with no mutations |\n | `backup` | Take a manual tar.gz snapshot of `~/.hermes/skills/` (curator also snapshots automatically before every real run) |\n | `rollback` | Restore `~/.hermes/skills/` from a snapshot (defaults to newest) |"]},{"file":"website/docs/user-guide/features/curator.md","hunks":["@@ -84,8 +84,8 @@ Earlier releases used a one-off `curator.auxiliary.{provider,model}` block. That\n \n ```bash\n hermes curator status         # last run, counts, pinned list, LRU top 5\n-hermes curator run            # trigger a review now (background by default)\n-hermes curator run --sync     # same, but block until the LLM pass finishes\n+hermes curator run            # trigger a review now (blocks until the LLM pass finishes)\n+hermes curator run --background  # fire-and-forget: start the LLM pass in a background thread\n hermes curator run --dry-run  # preview only — report without any mutations\n hermes curator backup         # take a manual snapshot of ~/.hermes/skills/\n hermes curator rollback       # restore from the newest snapshot\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}