Skip to content

fix(distribution): preserve profile skills on update - #110933

Closed
MohamadKanso wants to merge 2 commits into
NousResearch:mainfrom
MohamadKanso:fix/issue-110920-preserve-profile-skills
Closed

MohamadKanso wants to merge 2 commits into
NousResearch:mainfrom
MohamadKanso:fix/issue-110920-preserve-profile-skills

Conversation

@MohamadKanso

Copy link
Copy Markdown

summary

profile updates used to delete the whole skills directory before copying the staged one. that could remove custom skills and older bundled skills that were not in the newest distribution.

this change:

  • merges skills during profile updates instead of deleting the existing tree
  • still updates existing bundled skills and adds new ones
  • keeps fresh and forced installs unchanged
  • removes skills from the default distribution-owned path list

fixes #110920

testing

  • scripts/run_tests.sh tests/hermes_cli/test_profile_distribution.py -q — 54 passed
  • ruff check hermes_cli/profile_distribution.py tests/hermes_cli/test_profile_distribution.py
  • python3 scripts/check_compat_pointers.py
  • git diff --check

# Default distribution-owned paths (relative to profile root). Authors may override via
# ``distribution_owned:``. config.yaml is dist-owned but preserved on update by default.
DEFAULT_DIST_OWNED: Tuple[str, ...] = ("SOUL.md", "config.yaml", "mcp.json", "skills", "cron", MANIFEST_FILENAME)
DEFAULT_DIST_OWNED: Tuple[str, ...] = ("SOUL.md", "config.yaml", "mcp.json", "cron", MANIFEST_FILENAME)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I not sure it is that simple. There are factory skills that come along for the ride (and skills in subdirectories) but there are other skills in sub-directories that are part of Hermes locally generating self learned patterns.

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cli CLI entry point, hermes_cli/, setup wizard tool/skills Skills system (list, view, manage) area/profiles Multi-profile isolation, HERMES_HOME scoping area/install-update Installer, updater, packaging, wheels, doctor sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 14, 2026
@alt-glitch

Copy link
Copy Markdown

This was generated by AI during triage.

Competes with open #25150 (additive copytree in _copy_dist_payload) and #44386 (merge user entries in skills/ and cron/ on install --force / update) for the same rmtree-on-update data loss (#25120 / #110920). This PR is the narrowest current-main fix (update path only, drops skills from DEFAULT_DIST_OWNED); #44386 also protects cron/. Flagging so a maintainer picks one.

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reproduced three destructive edge cases that the current merge does not handle. The existing profile-distribution suite passes, but focused regressions fail at this exact head. The merge needs to distinguish user-owned skills from distribution-owned skill roots before this is safe to land.

target: Path,
manifest: DistributionManifest,
preserve_config: bool,
preserve_skills: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The new preservation switch defaults to false, and the force-install path does not enable it. Install a distribution, add skills/user-created/SKILL.md, then run install_distribution(..., force=True). The custom skill is deleted even though --force says user data is preserved. This leaves the same destructive copy behavior active in the other overwrite path. Please preserve unshipped skills during force reinstall too and add a regression test for this flow.

Comment thread hermes_cli/profile_distribution.py Outdated
if dest.exists() and not merge_skills:
shutil.rmtree(dest)
shutil.copytree(src, dest, ignore=_ignore_user_owned)
shutil.copytree(src, dest, ignore=_ignore_user_owned, dirs_exist_ok=merge_skills)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] dirs_exist_ok=True overlays files inside every staged skill root. If v1 ships skills/demo/retired.py and v2 removes it, an update leaves retired.py active. File-to-directory and directory-to-file transitions also produce the wrong result or raise shutil.Error. I reproduced these cases on this head, while the recorded base removes the stale entries. Please preserve skill roots absent from the new payload, but replace each skill root that the new distribution still ships.

Comment thread hermes_cli/profile_distribution.py Outdated
if dest.exists() and not merge_skills:
shutil.rmtree(dest)
shutil.copytree(src, dest, ignore=_ignore_user_owned)
shutil.copytree(src, dest, ignore=_ignore_user_owned, dirs_exist_ok=merge_skills)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This merge follows existing destination symlinks. I replaced skills/demo/helper.py with a symlink to a file outside the profile, then updated a distribution that ships that path. The update overwrote the external file. The recorded base does not do this because it removes the skills tree before copying. Please inspect destination entries without following symlinks and replace or reject symlinks before copying.

@JoaoMarcos44

Copy link
Copy Markdown

Independent validation of exact head 67b1d1542d4e5b0d772cea991886afad5bce418d:

The reported mechanism is real. On fetched current origin/main (345cd2b057a452236de401d3534b8502a7465e8d), a real-path temporary profile distribution update produced skills_after_update=bundled,new-bundled, custom_exists=False, and exit code 1. The custom skill was removed after _copy_dist_payload() reached the shutil.rmtree(dest) at the current profile_distribution.py copy branch.

The same reproduction on this PR produced skills_after_update=bundled,custom-agent,new-bundled, custom_exists=True, and exit code 0; the shipped skill was updated to v2. Replacing the PR source file temporarily with the current-base version and running the two new regression tests made both tests fail for the expected missing-custom-skill assertion (0 passed, 2 failed), then the PR source was restored.

Local verification of this exact head:

  • scripts/run_tests.sh tests/hermes_cli/test_profile_distribution.py -q: 51 passed, 3 skipped, 0 failed.
  • ruff check hermes_cli/profile_distribution.py tests/hermes_cli/test_profile_distribution.py: passed.
  • scripts/check_compat_pointers.py: passed.
  • git diff --check: passed.
  • The adjacent test_profiles.py run had the same 6 Windows permission/symlink failures on both the PR worktree and a clean current-base worktree; the distribution test file itself passed. These are not attributed to this diff.

The effective runtime fix is the update-only preserve_skills=True path and dirs_exist_ok merge in profile_distribution.py; this preserves existing skills while refreshing shipped files. Review note: on current main, _owned_entries() deliberately copies every non-excluded top-level entry when distribution_owned is omitted, so removing skills from DEFAULT_DIST_OWNED is a compatibility-list change, not the runtime protection. Please keep the PR description explicit about that.

The PR is open under MohamadKanso, has one commit with matching GitHub author/committer MoKanso, and currently reports no checks. Its base 62e5f466565ee56351e4483ead8e62f9e782f8b3 is 48 commits behind the fetched current main, although the touched source blob is unchanged. Please refresh the base or otherwise trigger the required checks before merge.

For the exact issue scope (hermes profile update losing profile skills), this is the narrow, independently verified canonical fix. install --force and the analogous cron/ directory behavior remain intentionally unchanged here; if those are in scope, consolidate with the separately owned broader work rather than opening a duplicate PR.

@MohamadKanso

Copy link
Copy Markdown
Author

thanks for the reproduction. i pushed c60656d. the update path now treats each top-level entry under skills as its own ownership boundary: shipped roots are replaced cleanly so stale files disappear, roots missing from the new payload stay, and file/directory swaps are safe. destination symlinks are removed before copying, while source symlinks are still rejected. forced reinstalls keep the same user-skill protection for existing profiles. added regressions for custom and stale roots, file/directory changes, symlink safety, and force reinstall. profile distribution tests: 56 passed, plus ruff, diff, and compat checks.

@kvnloo

kvnloo commented Sep 15, 2026

Copy link
Copy Markdown

Exact-head pick-one cross-link on #110933 head c60656d.

CHECK vs KEEP #111179 head 9ddc07a for profile distribution skill preservation. Close as salvaged.

Abort if head drifts.

1 similar comment
@kvnloo

kvnloo commented Sep 15, 2026

Copy link
Copy Markdown

Exact-head pick-one cross-link on #110933 head c60656d.

CHECK vs KEEP #111179 head 9ddc07a for profile distribution skill preservation. Close as salvaged.

Abort if head drifts.

kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 15, 2026
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 15, 2026
…t skills

The skills-only special case (a `preserve_skills` kwarg plus nested closures
keyed on the literal "skills") left cron/ and any future owned directory on
the old rmtree-then-copytree path, which deletes user-added files on update
and on --force reinstall. The rule is the same for all of them: a top-level
owned directory is a container of roots; replace only the roots the payload
ships, never the whole destination.

Apply that rule to every owned directory, drop the kwarg and the string
check, and lift the helpers to module level. `_remove_existing` keeps its
symlink-no-follow semantics and `_real_dir` replaces any symlink or file on
the destination path so the payload is never written outside the profile.
The fresh-install path had nothing to preserve, so it needs no flag.

Restore "skills" in DEFAULT_DIST_OWNED; removing it was never needed for the
fix. Add one invariant test: a user-added cron/ file survives an update.

Refs NousResearch#110933
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 15, 2026
Keep the tests that pin behaviour a regression would break: user-added and
stale skill roots survive an update while shipped roots are replaced, a
forced reinstall keeps a user skill, and a user-added cron file survives an
update. The allowlist and symlink-transition cases exercise the same code
path (`_real_dir` + `_replace_entry`) and were duplicates of those.

Refs NousResearch#110933
teknium1 pushed a commit that referenced this pull request Sep 15, 2026
…t skills

The skills-only special case (a `preserve_skills` kwarg plus nested closures
keyed on the literal "skills") left cron/ and any future owned directory on
the old rmtree-then-copytree path, which deletes user-added files on update
and on --force reinstall. The rule is the same for all of them: a top-level
owned directory is a container of roots; replace only the roots the payload
ships, never the whole destination.

Apply that rule to every owned directory, drop the kwarg and the string
check, and lift the helpers to module level. `_remove_existing` keeps its
symlink-no-follow semantics and `_real_dir` replaces any symlink or file on
the destination path so the payload is never written outside the profile.
The fresh-install path had nothing to preserve, so it needs no flag.

Restore "skills" in DEFAULT_DIST_OWNED; removing it was never needed for the
fix. Add one invariant test: a user-added cron/ file survives an update.

Refs #110933
teknium1 pushed a commit that referenced this pull request Sep 15, 2026
Keep the tests that pin behaviour a regression would break: user-added and
stale skill roots survive an update while shipped roots are replaced, a
forced reinstall keeps a user skill, and a user-added cron file survives an
update. The allowlist and symlink-transition cases exercise the same code
path (`_real_dir` + `_replace_entry`) and were duplicates of those.

Refs #110933
teknium1 pushed a commit that referenced this pull request Sep 15, 2026
…t skills

The skills-only special case (a `preserve_skills` kwarg plus nested closures
keyed on the literal "skills") left cron/ and any future owned directory on
the old rmtree-then-copytree path, which deletes user-added files on update
and on --force reinstall. The rule is the same for all of them: a top-level
owned directory is a container of roots; replace only the roots the payload
ships, never the whole destination.

Apply that rule to every owned directory, drop the kwarg and the string
check, and lift the helpers to module level. `_remove_existing` keeps its
symlink-no-follow semantics and `_real_dir` replaces any symlink or file on
the destination path so the payload is never written outside the profile.
The fresh-install path had nothing to preserve, so it needs no flag.

Restore "skills" in DEFAULT_DIST_OWNED; removing it was never needed for the
fix. Add one invariant test: a user-added cron/ file survives an update.

Refs #110933
teknium1 pushed a commit that referenced this pull request Sep 15, 2026
Keep the tests that pin behaviour a regression would break: user-added and
stale skill roots survive an update while shipped roots are replaced, a
forced reinstall keeps a user skill, and a user-added cron file survives an
update. The allowlist and symlink-transition cases exercise the same code
path (`_real_dir` + `_replace_entry`) and were duplicates of those.

Refs #110933
QuixThe2nd pushed a commit to QuixThe2nd/hermes-ide that referenced this pull request Sep 15, 2026
karljohannisson pushed a commit to karljohannisson/hermes-agent that referenced this pull request Sep 15, 2026
karljohannisson pushed a commit to karljohannisson/hermes-agent that referenced this pull request Sep 15, 2026
…t skills

The skills-only special case (a `preserve_skills` kwarg plus nested closures
keyed on the literal "skills") left cron/ and any future owned directory on
the old rmtree-then-copytree path, which deletes user-added files on update
and on --force reinstall. The rule is the same for all of them: a top-level
owned directory is a container of roots; replace only the roots the payload
ships, never the whole destination.

Apply that rule to every owned directory, drop the kwarg and the string
check, and lift the helpers to module level. `_remove_existing` keeps its
symlink-no-follow semantics and `_real_dir` replaces any symlink or file on
the destination path so the payload is never written outside the profile.
The fresh-install path had nothing to preserve, so it needs no flag.

Restore "skills" in DEFAULT_DIST_OWNED; removing it was never needed for the
fix. Add one invariant test: a user-added cron/ file survives an update.

Refs NousResearch#110933
karljohannisson pushed a commit to karljohannisson/hermes-agent that referenced this pull request Sep 15, 2026
Keep the tests that pin behaviour a regression would break: user-added and
stale skill roots survive an update while shipped roots are replaced, a
forced reinstall keeps a user skill, and a user-added cron file survives an
update. The allowlist and symlink-transition cases exercise the same code
path (`_real_dir` + `_replace_entry`) and were duplicates of those.

Refs NousResearch#110933
@MohamadKanso

Copy link
Copy Markdown
Author

closing this original PR in favor of merged #111344, which carried the implementation commits forward with the extra distribution safety follow-ups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: profile distribution treats skills/ as fully owned, wipes bundled + custom skills on update

6 participants