NousResearch/hermes-agent

Skill update backup file handling safety fixes

Cluster 44942 · skills. Data as of

Nothing open on GitHub. Fix merged in #45837. Check it covers your case.

The short answer

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

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.

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.

  • #45837 fix(skills): make bundled-update backup handling crash-safe

    merged to main

    files: scripts/release.py, tests/tools/test_skills_sync.py, tools/skills_sync.py

    Show the diff
    • scripts/release.py
      @@ -81,6 +81,7 @@
           "[redacted:email]": "synapsesx",
           "[redacted:email]": "itsflownium",
           "[redacted:email]": "dirtyren",
      +    "[redacted:email]": "MaxFreedomPollard",
           "[redacted:email]": "achaljhawar",
           "[redacted:email]": "claytonchew",
           "[redacted:email]": "hbentel",
    • tests/tools/test_skills_sync.py
      @@ -1,5 +1,6 @@
       """Tests for tools/skills_sync.py — manifest-based skill seeding and updating."""
       
      +import shutil
       import json
       from pathlib import Path
       from unittest.mock import patch
      @@ -1067,3 +1068,123 @@ def test_remove_keeps_user_modified(self, tmp_path):
                   assert "EDITED" in (skills_dir / "beta" / "SKILL.md").read_text()
                   # non-bundled local skill never considered
                   assert (skills_dir / "mine" / "SKILL.md").exists()
      +
      +
      +class TestUpdateBackupRecovery:
      +    """Regression tests for backup handling in the bundled-update path.
      +
      +    Covers three failure modes around ``dest.with_suffix(".bak")``:
      +    a stale backup poisoning the next update's move/restore, an orphaned
      +    backup (crash between move and copytree) being misread as a user
      +    deletion, and a partially-written dest blocking restore-on-failure.
      +    """
      +
      +    def _setup(self, tmp_path, bundled_text="# Old v2 (updated)"):
      +        """Bundled dir with one flat skill, plus user dirs."""
      +        bundled = tmp_path / "bundled_skills"
      +        (bundled / "old-skill").mkdir(parents=True)
      +        (bundled / "old-skill" / "SKILL.md").write_text(bundled_text)
      +        skills_dir = tmp_path / "user_skills"
      +        skills_dir.mkdir()
      +        manifest_file = skills_dir / ".bundled_manifest"
      +        return bundled, skills_dir, manifest_file
      +
      +    def _patches(self, bundled, skills_dir, manifest_file):
      +        from contextlib import ExitStack
      +        stack = ExitStack()
      +        stack.enter_context(patch("tools.skills_sync._get_bundled_dir", return_value=bundled))
      +        stack.enter_context(patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"))
      +        stack.enter_context(patch("tools.skills_sync.SKILLS_DIR", skills_dir))
      +        stack.enter_context(patch("tools.skills_sync.MANIFEST_FILE", manifest_file))
      +        return stack
      +
      +    def _seed_synced_copy(self, skills_dir, manifest_file, text="# Old v1"):
      +        """User copy of old-skill whose hash matches the manifest origin."""
      +        dest = skills_dir / "old-skill"
      +        dest.mkdir(parents=True)
      +        (dest / "SKILL.md").write_text(text)
      +        with patch("tools.skills_sync.MANIFEST_FILE", manifest_file):
      +            _write_manifest({"old-skill": _dir_hash(dest)})
      +        return dest
      +
      +    def test_stale_backup_does_not_poison_failed_update(self, tmp_path):
      +        """A leftover .bak must not nest the live copy or corrupt restore.
      +
      +        With a stale ``old-skill.bak`` present, ``shutil.move(dest, backup)``
      +        moves the live copy *inside* the stale dir. If copytree then fails,
      +        restore drags the stale junk (with the live copy nested in it) back
      +        to dest — corrupting the skill and wedging it as "user-modified".
      +        """
      +        bundled, skills_dir, manifest_file = self._setup(tmp_path)
      +        dest = self._seed_synced_copy(skills_dir, manifest_file)
      +
      +        stale = skills_dir / "old-skill.bak"
      +        stale.mkdir()
      +        (stale / "SKILL.md").write_text("# stale junk from an earlier failure")
      +
      +        def _boom(src, dst, **kwargs):
      +            raise OSError("simulated copy failure")
      +
      +        with self._patches(bundled, skills_dir, manifest_file), \
      +                patch("tools.skills_sync.shutil.copytree", side_effect=_boom):
      +            sync_skills(quiet=True)
      +
      +        # The live copy must survive the failed update untouched...
      +        assert (dest / "SKILL.md").read_text() == "# Old v1"
      +        # ...not be nested inside recycled stale-backup content.
      +        assert not (dest / "old-skill").exists()
      +        # And no backup directory may linger.
      +        assert not (skills_dir / "old-skill.bak").exists()
      +
      +    def test_orphaned_backup_is_recovered_not_treated_as_deleted(self, tmp_path):
      +        """Crash between move and copytree must not lose the skill.
      +
      +        After such a crash, dest is gone and the user's only copy sits in
      +        ``old-skill.bak``. The sync loop's "in manifest but not on disk"
      +        branch reads that as a deliberate user deletion and skips — the
      +        skill silently vanishes. It must instead be recovered and updated.
      +        """
      +        bundled, skills_dir, manifest_file = self._setup(tmp_path)
      +        dest = self._seed_synced_copy(skills_dir, manifest_file)
      +        # Simulate the crash: dest was moved aside, new copy never arrived.
      +        shutil.move(str(dest), str(skills_dir / "old-skill.bak"))
      +
      +        with self._patches(bundled, skills_dir, manifest_file):
      +            result = sync_skills(quiet=True)
      +
      +        # Recovered and then updated to the new bundled version in one run.
      +        assert (dest / "SKILL.md").exists()
      +        assert (dest / "SKILL.md").read_text() == "# Old v2 (updated)"
      +        assert "old-skill" in result["updated"]
      +        assert not (skills_dir / "old-skill.bak").exists()
      +
      +    def test_partial_copy_failure_restores_original(self, tmp_path):
      +        """A half-written dest must not block restore-on-failure.
      +
      +        If copytree dies after creating dest, the ``not dest.exists()``
      +        guard skips the restore: the user keeps a broken partial skill,
      +        the .bak lingers, and the partial hash wedges the skill as
      +        "user-modified" on every later sync.
      +        """
      +        bundled, skills_dir, manifest_file = self._setup(tmp_path)
      +        dest = self._seed_synced_copy(skills_dir, manifest_file)
      +
      +        def _partial_then_fail(src, dst, **kwargs):
      +            Path(dst).mkdir(parents=True, exist_ok=True)
      +            (Path(dst) / "PARTIAL").write_text("half-written")
      +            raise OSError("simulated failure mid-copy")
      +
      +        with self._patches(bundled, skills_dir, manifest_file), \
      +                patch("tools.skills_sync.shutil.copytree", side_effect=_partial_then_fail):
      +            sync_skills(quiet=True)
      +
      +        # Original content restored, partial debris and backup gone.
      +        assert (dest / "SKILL.md").read_text() == "# Old v1"
      +        assert not (dest / "PARTIAL").exists()
      +        assert not (skills_dir / "old-skill.bak").exists()
      +
      +        # And the skill is not wedged: a later normal sync updates cleanly.
      +        with self._patches(bundled, skills_dir, manifest_file):
      +            result2 = sync_skills(quiet=True)
      +        assert "old-skill" in result2["updated"]
      +        assert result2["user_modified"] == []
    • tools/skills_sync.py
      @@ -506,6 +506,24 @@ def sync_skills(quiet: bool = False) -> dict:
               dest = _compute_relative_dest(skill_src, bundled_dir)
               bundled_hash = _dir_hash(skill_src)
       
      +        # Recover an orphaned backup before classifying. If a previous
      +        # update was interrupted between moving dest aside and copying the
      +        # new version in, the user's only copy sits in ``dest.bak`` while
      +        # dest is gone — without this, the "in manifest but not on disk"
      +        # branch below misreads the skill as user-deleted and it silently
      +        # vanishes from discovery.
      +        _orphan = dest.with_suffix(".bak")
      +        if _orphan.exists() and not dest.exists():
      +            try:
      +                dest.parent.mkdir(parents=True, exist_ok=True)
      +                shutil.move(str(_orphan), str(dest))
      +                logger.info("Recovered orphaned skill backup: %s", _orphan)
      +            except (OSError, IOError):
      +                logger.warning(
      +                    "Could not recover orphaned skill backup %s", _orphan,
      +                    exc_info=True,
      +                )
      +
               if skill_name not in manifest:
                   # ── New skill — never offered before ──
                   try:
      @@ -569,6 +587,12 @@ def sync_skills(quiet: bool = False) -> dict:
                       try:
                           # Move old copy to a backup so we can restore on failure
                           backup = dest.with_suffix(".bak")
      +                    # A stale backup left by an earlier failure would make
      +                    # shutil.move() nest dest *inside* it (or fail outright)
      +                    # and would poison the restore path below. The current
      +                    # dest is the authoritative copy — clear the leftover.
      +                    if backup.exists():
      +                        _rmtree_writable(backup)
                           shutil.move(str(dest), str(backup))
                           try:
                               shutil.copytree(skill_src, dest)
      @@ -582,9 +606,20 @@ def sync_skills(quiet: bool = False) -> dict:
                               except (OSError, IOError):
                                   logger.debug("Could not remove backup %s", backup, exc_info=True)
                           except (OSError, IOError):
      -                        # Restore from backup
      -                        if backup.exists() and not dest.exists():
      -                            shutil.move(str(backup), str(dest))
      +                        # Restore from backup. A partially-written dest must
      +                        # not shadow the user's copy or block the restore —
      +                        # clear it first, then move the backup home.
      +                        if backup.exists():
      +                            if dest.exists():
      +                                try:
      +                                    _rmtree_writable(dest)
      +                                except (OSError, IOError):
      +                                    logger.warning(
      +                                        "Could not clear partial copy %s during restore",
      +                                        dest, exc_info=True,
      +                                    )
      +                            if not dest.exists():
      +                                shutil.move(str(backup), str(dest))
                               raise
                       except (OSError, IOError) as e:
                           if not quiet:

3 PRs closed without merging

From the close comments: duplicate 1 · partial fix 1 · superseded by another PR 1

  • #44951 fix(skills): make bundled-update backup handling crash-safe and idempotent · partial fix → 3581131, #45837
  • #45033 fix(skills_sync): handle stale/orphaned .bak in skill-update restore logic (#44942) · duplicate → #44951, #44942
  • #45124 fix(skills): clean stale .bak before backup move in skill-update (#44942) · superseded by another PR → #44951, #44942
Closed issues · 1
  • Closed#44942[Bug]: skill-update backup handling can corrupt or silently lose skills (stale/orphaned .bak interactions)
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.40: 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

Subsystem
skills
Merged PRs
#45837
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
13 Jun 2026

For your agent

The same facts, typed.