{"repo":"NousResearch/hermes-agent","public_id":"35376","display_label":"SQLite backup staging path and exclusion fixes","summary":"A bug where SQLite DB snapshots were staged in /tmp (risking incomplete backups) is being addressed, along with fixes to stop incorrectly excluding nested hermes-agent skill directories during backup. Multiple related fix attempts appear across these members.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44082,"merged_at":"2026-06-11T07:20:52+00:00"},"resolution":{"state":"resolved","by":"pr-44082"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T07:20:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":35376,"pr_number":44082,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-11T07:20:52+00:00"}],"members":[{"kind":"issue","number":35376,"title":"[Bug]:  Hermes backup stages SQLite DB snapshots in /tmp, causing incomplete backups when state.db is large","url":null,"state":"closed"},{"kind":"pr","number":35379,"title":"fix(backup): stage SQLite snapshots alongside output zip and stop excluding nested hermes-agent skill dirs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44082","number":44082},{"kind":"pr","value":"#35376","number":35376}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35496,"title":"fix: route SQLite backup staging files to HERMES_HOME/tmp","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44082","number":44082},{"kind":"pr","value":"#35376","number":35376}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44082,"title":"fix(backup): stage SQLite snapshots beside output zip (all paths) and stop excluding nested hermes-agent skill dirs","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/backup.py","tests/hermes_cli/test_backup.py"],"fix_hunks":[{"file":"hermes_cli/backup.py","hunks":["@@ -31,6 +31,9 @@\n # ---------------------------------------------------------------------------\n \n # Directory names to skip entirely (matched against each path component)\n+# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``\n+# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``\n+# are not accidentally excluded.\n _EXCLUDED_DIRS = {\n     \"hermes-agent\",     # the codebase repo — re-clone instead\n     \"__pycache__\",      # bytecode caches — regenerated on import","@@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool:\n     \"\"\"Return True if *rel_path* (relative to hermes root) should be skipped.\"\"\"\n     parts = rel_path.parts\n \n-    # Any path component matches an excluded dir name\n     for part in parts:\n-        if part in _EXCLUDED_DIRS:\n-            return True\n+        if part not in _EXCLUDED_DIRS:\n+            continue\n+        # ``hermes-agent`` only matches at the root level (first component).\n+        # Nested directories with the same name — e.g.\n+        # ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved.\n+        if part == \"hermes-agent\" and part != parts[0]:\n+            continue\n+        return True\n \n     name = rel_path.name\n ","@@ -177,10 +185,13 @@ def run_backup(args) -> None:\n         rel_dir = dp.relative_to(hermes_root)\n \n         # Prune excluded directories in-place so os.walk doesn't descend\n+        # ``hermes-agent`` is only pruned at the root level; nested dirs\n+        # with the same name (e.g. in skills/) must be preserved.\n+        is_root = rel_dir == Path(\".\")\n         orig_dirnames = dirnames[:]\n         dirnames[:] = [\n             d for d in dirnames\n-            if d not in _EXCLUDED_DIRS\n+            if d not in _EXCLUDED_DIRS or (d == \"hermes-agent\" and not is_root)\n         ]\n         for removed in set(orig_dirnames) - set(dirnames):\n             skipped_dirs.add(str(rel_dir / removed))","@@ -211,7 +222,13 @@ def run_backup(args) -> None:\n             try:\n                 # Safe copy for SQLite databases (handles WAL mode)\n                 if abs_path.suffix == \".db\":\n-                    with tempfile.NamedTemporaryFile(suffix=\".db\", delete=False) as tmp:\n+                    # Stage the snapshot alongside the output zip so that the\n+                    # temp file lives on the same filesystem.  The system\n+                    # default (/tmp) may be a small tmpfs that cannot hold\n+                    # large databases, causing silent backup incompleteness.\n+                    with tempfile.NamedTemporaryFile(\n+                        suffix=\".db\", delete=False, dir=str(out_path.parent)\n+                    ) as tmp:\n                         tmp_db = Path(tmp.name)\n                     if _safe_copy_db(abs_path, tmp_db):\n                         zf.write(tmp_db, arcname=str(rel_path))","@@ -853,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:\n             for abs_path, rel_path in files_to_add:\n                 try:\n                     if abs_path.suffix == \".db\":\n-                        with tempfile.NamedTemporaryFile(suffix=\".db\", delete=False) as tmp:\n+                        # Stage the snapshot alongside the output zip so that the\n+                        # temp file lives on the same filesystem.  The system\n+                        # default (/tmp) may be a small tmpfs that cannot hold\n+                        # large databases, causing silent backup incompleteness.\n+                        with tempfile.NamedTemporaryFile(\n+                            suffix=\".db\", delete=False, dir=str(out_path.parent)\n+                        ) as tmp:\n                             tmp_db = Path(tmp.name)\n                         try:\n                             if _safe_copy_db(abs_path, tmp_db):"]},{"file":"tests/hermes_cli/test_backup.py","hunks":["@@ -146,6 +146,12 @@ def test_includes_logs(self):\n         from hermes_cli.backup import _should_exclude\n         assert not _should_exclude(Path(\"logs/agent.log\"))\n \n+    def test_includes_nested_hermes_agent_in_skills(self):\n+        \"\"\"skills/autonomous-ai-agents/hermes-agent/ must NOT be excluded —\n+        only the root-level hermes-agent/ repo is skipped.\"\"\"\n+        from hermes_cli.backup import _should_exclude\n+        assert not _should_exclude(Path(\"skills/autonomous-ai-agents/hermes-agent/SKILL.md\"))\n+        assert not _should_exclude(Path(\"skills/autonomous-ai-agents/hermes-agent/sub/item.txt\"))\n \n # ---------------------------------------------------------------------------\n # Backup tests","@@ -186,6 +192,66 @@ def test_creates_zip(self, tmp_path, monkeypatch):\n             # Skins\n             assert \"skins/cyber.yaml\" in names\n \n+    def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):\n+        \"\"\"SQLite staging temp files must be created on the output zip's\n+        filesystem (dir=out_path.parent), NOT the system /tmp default — a\n+        small tmpfs there silently drops large DBs from the backup (#35376).\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        _make_hermes_tree(hermes_home)\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+\n+        out_dir = tmp_path / \"external-drive\"\n+        out_dir.mkdir()\n+        out_zip = out_dir / \"backup.zip\"\n+        args = Namespace(output=str(out_zip))\n+\n+        import hermes_cli.backup as backup_mod\n+        staged_dirs = []\n+        real_ntf = backup_mod.tempfile.NamedTemporaryFile\n+\n+        def _spy(*a, **kw):\n+            staged_dirs.append(kw.get(\"dir\"))\n+            return real_ntf(*a, **kw)\n+\n+        monkeypatch.setattr(backup_mod.tempfile, \"NamedTemporaryFile\", _spy)\n+        backup_mod.run_backup(args)\n+\n+        # At least one .db was staged, and every staging call targeted the\n+        # output zip's directory rather than the system temp default.\n+        assert staged_dirs, \"no SQLite snapshot was staged\"\n+        assert all(d == str(out_dir) for d in staged_dirs), staged_dirs\n+\n+    def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):\n+        \"\"\"The pre-update/pre-migration zip path (_write_full_zip_backup) must\n+        also stage SQLite snapshots beside its output zip, not in /tmp.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        _make_hermes_tree(hermes_home)\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+\n+        out_zip = hermes_home / \"backups\" / \"pre-update-test.zip\"\n+        out_zip.parent.mkdir(parents=True, exist_ok=True)\n+\n+        import hermes_cli.backup as backup_mod\n+        staged_dirs = []\n+        real_ntf = backup_mod.tempfile.NamedTemporaryFile\n+\n+        def _spy(*a, **kw):\n+            staged_dirs.append(kw.get(\"dir\"))\n+            return real_ntf(*a, **kw)\n+\n+        monkeypatch.setattr(backup_mod.tempfile, \"NamedTemporaryFile\", _spy)\n+        result = backup_mod._write_full_zip_backup(out_zip, hermes_home)\n+\n+        assert result is not None\n+        assert staged_dirs, \"no SQLite snapshot was staged\"\n+        assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs\n+\n     def test_excludes_hermes_agent(self, tmp_path, monkeypatch):\n         \"\"\"Backup does NOT include hermes-agent/ directory.\"\"\"\n         hermes_home = tmp_path / \".hermes\"","@@ -206,6 +272,37 @@ def test_excludes_hermes_agent(self, tmp_path, monkeypatch):\n             agent_files = [n for n in names if \"hermes-agent\" in n]\n             assert agent_files == [], f\"hermes-agent files leaked into backup: {agent_files}\"\n \n+    def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch):\n+        \"\"\"Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        _make_hermes_tree(hermes_home)\n+\n+        # Add a nested hermes-agent directory inside skills (like the real layout)\n+        nested = hermes_home / \"skills\" / \"autonomous-ai-agents\" / \"hermes-agent\"\n+        nested.mkdir(parents=True)\n+        (nested / \"SKILL.md\").write_text(\"# Hermes Agent Skill\\n\")\n+        (nested / \"sub\").mkdir()\n+        (nested / \"sub\" / \"item.txt\").write_text(\"nested content\\n\")\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+        monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+\n+        out_zip = tmp_path / \"backup.zip\"\n+        args = Namespace(output=str(out_zip))\n+\n+        from hermes_cli.backup import run_backup\n+        run_backup(args)\n+\n+        with zipfile.ZipFile(out_zip, \"r\") as zf:\n+            names = zf.namelist()\n+            # Root hermes-agent must be excluded\n+            root_agent = [n for n in names if n.startswith(\"hermes-agent/\")]\n+            assert root_agent == [], f\"root hermes-agent leaked: {root_agent}\"\n+            # Nested skill hermes-agent must be included\n+            assert \"skills/autonomous-ai-agents/hermes-agent/SKILL.md\" in names\n+            assert \"skills/autonomous-ai-agents/hermes-agent/sub/item.txt\" in names\n+\n     def test_excludes_pycache(self, tmp_path, monkeypatch):\n         \"\"\"Backup does NOT include __pycache__ dirs.\"\"\"\n         hermes_home = tmp_path / \".hermes\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}