{"repo":"NousResearch/hermes-agent","public_id":"44942","display_label":"Skill update backup file handling safety fixes","summary":"These issues and pull requests address unsafe or non-idempotent behavior in the skill-update backup mechanism, specifically around stale or orphaned .bak files that can cause corruption or silent data loss. The work focuses on making backup creation and restore logic crash-safe and reliable.","subsystem":"skills","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45837},"resolution":{"state":"resolved","by":"pr-45837"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T22:01:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":44942,"pr_number":45837,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":44942,"title":"[Bug]: skill-update backup handling can corrupt or silently lose skills (stale/orphaned .bak interactions)","url":null,"state":"closed"},{"kind":"pr","number":44951,"title":"fix(skills): make bundled-update backup handling crash-safe and idempotent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3581131e7de1560633c921b4782ea87dcbac3a9e","number":0},{"kind":"pr","value":"#45837","number":45837}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45033,"title":"fix(skills_sync): handle stale/orphaned .bak in skill-update restore logic (#44942)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44951","number":44951},{"kind":"pr","value":"#44942","number":44942},{"kind":"pr","value":"#44951","number":44951}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45124,"title":"fix(skills): clean stale .bak before backup move in skill-update (#44942)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44951","number":44951},{"kind":"pr","value":"#44942","number":44942}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45837,"title":"fix(skills): make bundled-update backup handling crash-safe","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_skills_sync.py","tools/skills_sync.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -81,6 +81,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"MaxFreedomPollard\",\n     \"[redacted:email]\": \"achaljhawar\",\n     \"[redacted:email]\": \"claytonchew\",\n     \"[redacted:email]\": \"hbentel\","]},{"file":"tests/tools/test_skills_sync.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for tools/skills_sync.py — manifest-based skill seeding and updating.\"\"\"\n \n+import shutil\n import json\n from pathlib import Path\n from unittest.mock import patch","@@ -1067,3 +1068,123 @@ def test_remove_keeps_user_modified(self, tmp_path):\n             assert \"EDITED\" in (skills_dir / \"beta\" / \"SKILL.md\").read_text()\n             # non-bundled local skill never considered\n             assert (skills_dir / \"mine\" / \"SKILL.md\").exists()\n+\n+\n+class TestUpdateBackupRecovery:\n+    \"\"\"Regression tests for backup handling in the bundled-update path.\n+\n+    Covers three failure modes around ``dest.with_suffix(\".bak\")``:\n+    a stale backup poisoning the next update's move/restore, an orphaned\n+    backup (crash between move and copytree) being misread as a user\n+    deletion, and a partially-written dest blocking restore-on-failure.\n+    \"\"\"\n+\n+    def _setup(self, tmp_path, bundled_text=\"# Old v2 (updated)\"):\n+        \"\"\"Bundled dir with one flat skill, plus user dirs.\"\"\"\n+        bundled = tmp_path / \"bundled_skills\"\n+        (bundled / \"old-skill\").mkdir(parents=True)\n+        (bundled / \"old-skill\" / \"SKILL.md\").write_text(bundled_text)\n+        skills_dir = tmp_path / \"user_skills\"\n+        skills_dir.mkdir()\n+        manifest_file = skills_dir / \".bundled_manifest\"\n+        return bundled, skills_dir, manifest_file\n+\n+    def _patches(self, bundled, skills_dir, manifest_file):\n+        from contextlib import ExitStack\n+        stack = ExitStack()\n+        stack.enter_context(patch(\"tools.skills_sync._get_bundled_dir\", return_value=bundled))\n+        stack.enter_context(patch(\"tools.skills_sync._get_optional_dir\", return_value=bundled.parent / \"optional-skills\"))\n+        stack.enter_context(patch(\"tools.skills_sync.SKILLS_DIR\", skills_dir))\n+        stack.enter_context(patch(\"tools.skills_sync.MANIFEST_FILE\", manifest_file))\n+        return stack\n+\n+    def _seed_synced_copy(self, skills_dir, manifest_file, text=\"# Old v1\"):\n+        \"\"\"User copy of old-skill whose hash matches the manifest origin.\"\"\"\n+        dest = skills_dir / \"old-skill\"\n+        dest.mkdir(parents=True)\n+        (dest / \"SKILL.md\").write_text(text)\n+        with patch(\"tools.skills_sync.MANIFEST_FILE\", manifest_file):\n+            _write_manifest({\"old-skill\": _dir_hash(dest)})\n+        return dest\n+\n+    def test_stale_backup_does_not_poison_failed_update(self, tmp_path):\n+        \"\"\"A leftover .bak must not nest the live copy or corrupt restore.\n+\n+        With a stale ``old-skill.bak`` present, ``shutil.move(dest, backup)``\n+        moves the live copy *inside* the stale dir. If copytree then fails,\n+        restore drags the stale junk (with the live copy nested in it) back\n+        to dest — corrupting the skill and wedging it as \"user-modified\".\n+        \"\"\"\n+        bundled, skills_dir, manifest_file = self._setup(tmp_path)\n+        dest = self._seed_synced_copy(skills_dir, manifest_file)\n+\n+        stale = skills_dir / \"old-skill.bak\"\n+        stale.mkdir()\n+        (stale / \"SKILL.md\").write_text(\"# stale junk from an earlier failure\")\n+\n+        def _boom(src, dst, **kwargs):\n+            raise OSError(\"simulated copy failure\")\n+\n+        with self._patches(bundled, skills_dir, manifest_file), \\\n+                patch(\"tools.skills_sync.shutil.copytree\", side_effect=_boom):\n+            sync_skills(quiet=True)\n+\n+        # The live copy must survive the failed update untouched...\n+        assert (dest / \"SKILL.md\").read_text() == \"# Old v1\"\n+        # ...not be nested inside recycled stale-backup content.\n+        assert not (dest / \"old-skill\").exists()\n+        # And no backup directory may linger.\n+        assert not (skills_dir / \"old-skill.bak\").exists()\n+\n+    def test_orphaned_backup_is_recovered_not_treated_as_deleted(self, tmp_path):\n+        \"\"\"Crash between move and copytree must not lose the skill.\n+\n+        After such a crash, dest is gone and the user's only copy sits in\n+        ``old-skill.bak``. The sync loop's \"in manifest but not on disk\"\n+        branch reads that as a deliberate user deletion and skips — the\n+        skill silently vanishes. It must instead be recovered and updated.\n+        \"\"\"\n+        bundled, skills_dir, manifest_file = self._setup(tmp_path)\n+        dest = self._seed_synced_copy(skills_dir, manifest_file)\n+        # Simulate the crash: dest was moved aside, new copy never arrived.\n+        shutil.move(str(dest), str(skills_dir / \"old-skill.bak\"))\n+\n+        with self._patches(bundled, skills_dir, manifest_file):\n+            result = sync_skills(quiet=True)\n+\n+        # Recovered and then updated to the new bundled version in one run.\n+        assert (dest / \"SKILL.md\").exists()\n+        assert (dest / \"SKILL.md\").read_text() == \"# Old v2 (updated)\"\n+        assert \"old-skill\" in result[\"updated\"]\n+        assert not (skills_dir / \"old-skill.bak\").exists()\n+\n+    def test_partial_copy_failure_restores_original(self, tmp_path):\n+        \"\"\"A half-written dest must not block restore-on-failure.\n+\n+        If copytree dies after creating dest, the ``not dest.exists()``\n+        guard skips the restore: the user keeps a broken partial skill,\n+        the .bak lingers, and the partial hash wedges the skill as\n+        \"user-modified\" on every later sync.\n+        \"\"\"\n+        bundled, skills_dir, manifest_file = self._setup(tmp_path)\n+        dest = self._seed_synced_copy(skills_dir, manifest_file)\n+\n+        def _partial_then_fail(src, dst, **kwargs):\n+            Path(dst).mkdir(parents=True, exist_ok=True)\n+            (Path(dst) / \"PARTIAL\").write_text(\"half-written\")\n+            raise OSError(\"simulated failure mid-copy\")\n+\n+        with self._patches(bundled, skills_dir, manifest_file), \\\n+                patch(\"tools.skills_sync.shutil.copytree\", side_effect=_partial_then_fail):\n+            sync_skills(quiet=True)\n+\n+        # Original content restored, partial debris and backup gone.\n+        assert (dest / \"SKILL.md\").read_text() == \"# Old v1\"\n+        assert not (dest / \"PARTIAL\").exists()\n+        assert not (skills_dir / \"old-skill.bak\").exists()\n+\n+        # And the skill is not wedged: a later normal sync updates cleanly.\n+        with self._patches(bundled, skills_dir, manifest_file):\n+            result2 = sync_skills(quiet=True)\n+        assert \"old-skill\" in result2[\"updated\"]\n+        assert result2[\"user_modified\"] == []"]},{"file":"tools/skills_sync.py","hunks":["@@ -506,6 +506,24 @@ def sync_skills(quiet: bool = False) -> dict:\n         dest = _compute_relative_dest(skill_src, bundled_dir)\n         bundled_hash = _dir_hash(skill_src)\n \n+        # Recover an orphaned backup before classifying. If a previous\n+        # update was interrupted between moving dest aside and copying the\n+        # new version in, the user's only copy sits in ``dest.bak`` while\n+        # dest is gone — without this, the \"in manifest but not on disk\"\n+        # branch below misreads the skill as user-deleted and it silently\n+        # vanishes from discovery.\n+        _orphan = dest.with_suffix(\".bak\")\n+        if _orphan.exists() and not dest.exists():\n+            try:\n+                dest.parent.mkdir(parents=True, exist_ok=True)\n+                shutil.move(str(_orphan), str(dest))\n+                logger.info(\"Recovered orphaned skill backup: %s\", _orphan)\n+            except (OSError, IOError):\n+                logger.warning(\n+                    \"Could not recover orphaned skill backup %s\", _orphan,\n+                    exc_info=True,\n+                )\n+\n         if skill_name not in manifest:\n             # ── New skill — never offered before ──\n             try:","@@ -569,6 +587,12 @@ def sync_skills(quiet: bool = False) -> dict:\n                 try:\n                     # Move old copy to a backup so we can restore on failure\n                     backup = dest.with_suffix(\".bak\")\n+                    # A stale backup left by an earlier failure would make\n+                    # shutil.move() nest dest *inside* it (or fail outright)\n+                    # and would poison the restore path below. The current\n+                    # dest is the authoritative copy — clear the leftover.\n+                    if backup.exists():\n+                        _rmtree_writable(backup)\n                     shutil.move(str(dest), str(backup))\n                     try:\n                         shutil.copytree(skill_src, dest)","@@ -582,9 +606,20 @@ def sync_skills(quiet: bool = False) -> dict:\n                         except (OSError, IOError):\n                             logger.debug(\"Could not remove backup %s\", backup, exc_info=True)\n                     except (OSError, IOError):\n-                        # Restore from backup\n-                        if backup.exists() and not dest.exists():\n-                            shutil.move(str(backup), str(dest))\n+                        # Restore from backup. A partially-written dest must\n+                        # not shadow the user's copy or block the restore —\n+                        # clear it first, then move the backup home.\n+                        if backup.exists():\n+                            if dest.exists():\n+                                try:\n+                                    _rmtree_writable(dest)\n+                                except (OSError, IOError):\n+                                    logger.warning(\n+                                        \"Could not clear partial copy %s during restore\",\n+                                        dest, exc_info=True,\n+                                    )\n+                            if not dest.exists():\n+                                shutil.move(str(backup), str(dest))\n                         raise\n                 except (OSError, IOError) as e:\n                     if not quiet:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}