Repository navigation
fix(skills): park a batch rollback outside the skills root - #118692
yoyodine-industries wants to merge 76 commits into
Conversation
The failure path moved the half-applied skill aside as a sibling (<name>.rollback-broken) INSIDE the skills root and then removed it with shutil.rmtree. Two consequences: * a process killed between the park and the copy-back left an entry that the loader offers as a skill (it walks a root following symlinked entries and prunes only EXCLUDED_SKILL_DIRS); when that park's SKILL.md declares the live skill's frontmatter name, the name resolves twice in a single root; * a park that WAS a symlink (a profile that links each skill into a shared tree) could never be removed at all: shutil.rmtree refuses a symlink, and ignore_errors=True swallowed the refusal. Park in a hidden directory beside the skills root instead. It stays on the same filesystem, so the rename stays atomic, and it falls back to the system temp dir when HERMES_HOME is itself configured as a skills root so the park is never walked. Remove it with a symlink-aware helper, and sweep parks older than a day so one abandoned by a killed process is not left behind forever. Tests: tests/tools/test_skill_batch_rollback_park.py — a rollback restores the skill and leaves only the skill loadable; the park lands outside the root for a real directory and for a symlinked entry; a SIGKILL mid-restore leaves nothing loadable in the root; a stale park is swept and a fresh one survives. Authored with AI assistance (Hermes Agent); diff verified manually.
arkheioncorp
left a comment
There was a problem hiding this comment.
APPROVE
Security scan: Clean.
Logic review: Two real bugs fixed: (1) shutil.rmtree refuses symlinks, so a symlinked park could never be removed; _remove_path now dispatches on is_symlink(). (2) Parks were created inside the skills root as siblings, making them loadable skills; _park_entry_aside now places them under .skill-rollback-asides off-root. Stale park sweep runs on every rollback. Failures in _remove_path are logged but not retried — acceptable for a rollback cleanup path.
Suggestions: _asides_root() catches all exceptions and falls back to tempfile.gettempdir() silently. If the skills-root check itself is what is failing (e.g. a resolver bug), the park still lands safely, but the root cause is masked. Consider logging the fallback at WARNING level.
JoaoMarcos44
left a comment
There was a problem hiding this comment.
Exact-head adversarial review at 2fbc6a3a13f509b1bd2fc2a0f3e0d4935cbbef85. The same-filesystem rename path closes the reported loadable-shadow failure, and symlink-aware cleanup is correct. I found one blocking portability/integrity gap in the explicitly supported off-tree fallback: when _asides_root() resolves onto another filesystem, shutil.move is copy-then-delete, so the operation loses the atomic evacuation property the crash-safety argument depends on. Details inline.
| try: | ||
| path.rename(aside) | ||
| except OSError: # another filesystem: a move copies first and never deletes on failure | ||
| shutil.move(str(path), str(aside)) |
There was a problem hiding this comment.
P1 BLOCKER — the cross-filesystem fallback reintroduces a loadable in-root entry during the crash window.
REPRODUCTION: configure the layout that makes _asides_root() return tempfile.gettempdir() (the code explicitly does this when the normal aside base would itself be under a walked skills root), with that temp directory mounted on a different filesystem from the skill. path.rename(aside) raises EXDEV, so this branch calls shutil.move. For a directory across filesystems, shutil.move copies the tree to aside and removes the source only after the copy succeeds. Kill/power-fail during that copy: the original skill still exists inside the walked root and is loadable, while a partial park exists off-tree. The exact invariant this PR advertises — interruption after parking leaves nothing loadable inside the root — is therefore false on the fallback path.
DIAGNOSIS: crash safety here depends on an atomic namespace move. Cross-device copy+delete cannot provide that invariant. The current interruption regression uses one temp hierarchy, so source and aside normally share a filesystem and never exercises EXDEV.
FIX: choose an aside directory guaranteed to be on the source filesystem but outside every loader root (derive a safe sibling from the source mount/root topology), or fail closed before moving when no same-filesystem non-loadable location can be established. Do not describe shutil.move as a safe equivalent for the crash window.
REGRESSION: force Path.rename to raise EXDEV (or use two test mounts), pause/kill during the fallback copy, and assert the loader cannot observe a loadable source entry. If that guarantee is impossible cross-device, assert rollback aborts without beginning the park and reports the limitation rather than entering a non-atomic half-move.
There was a problem hiding this comment.
Confirmed. The full disposition, the fix and the regression shape are in my comment on this PR. Short version: the gap is real because rename is atomic and shutil.move across filesystems is not, the fix is a same-filesystem aside outside every loader root or a fail-closed abort before any move, and the regression forces EXDEV and kills mid-copy.
…park-outside-root
|
Accepted — the finding is real and the description cannot stay as it is.
Fix as you specify: derive the aside from the source's own mount/root topology so it is on the source filesystem and outside every loader root, and where no such location can be established, fail closed before any move rather than entering a non-atomic half-move — and stop describing Regression: force arkheioncorp's APPROVE holds for the same-filesystem path, which is the path you also verified; this blocker is the other one. A change follows on this branch and I will post the head sha with it. |
Absorbs upstream main into the branch so PR NousResearch#118692 is mergeable again (4504 commits behind). One conflict, `tools/skill_manager_batch.py`: upstream reworked `_restore_snapshot` to handle a `create` that ADOPTED a pre-existing empty dir (the `dir_pre_existed` / `written` args, unlink-and-rmdir instead of rmtree), and this branch reworked the same function to park the broken entry OUTSIDE every skills root and remove it with a symlink-aware helper (`_remove_path`) instead of `shutil.rmtree`. Resolution: union — both behaviours are kept, neither feature is dropped. - Upstream's signature `_restore_snapshot(pre_dir, snap, post_dir, dir_pre_existed=False, written=())` and its adopted-empty-dir rollback path are kept as upstream wrote them, except that the batch-created case removes via this branch's `_remove_path` (symlink-aware, best-effort) rather than a bare `shutil.rmtree`, which refuses a symlink and would raise from a rollback. - This branch's `_remove_path`, `_asides_root`, `_sweep_stale_asides` and `_park_entry_aside` are kept, and the park/failed-restore paths call `_park_entry_aside` / `_remove_path` exactly as this branch did. - The two are independent by construction: upstream's path never parks, and this branch's park path is unchanged by upstream's new arguments. Verification (merged tree): 55 skill-surface files, 849 passed / 0 failed / 11 skipped via `scripts/run_tests_parallel.py -j 8 tests/*/test_skill*.py tests/*/test_skills*.py`, and 15 passed on `tests/tools/test_skill_batch_rollback_park.py tests/tools/test_skill_manage_batch.py`. 0 conflict markers remain in the resolved file.
The park evacuated the broken entry out of the skills root with `path.rename()`, falling back to `shutil.move` on any `OSError`. Across filesystems that fallback is copy-then-delete, so a process killed mid-copy leaves the original still inside the walked root and loadable while a partial park sits off-tree — the exact state the park exists to prevent, and the opposite of the atomic evacuation the rest of this change advertises. - `_park_dir_for()` derives the park from the entry's own root topology: the `.skill-rollback-asides` sibling of each ancestor outward from the root that holds the entry, taking the first candidate that is outside every skills root AND on the entry's own filesystem (`st_dev` of the nearest existing ancestor, re-checked after the mkdir). The tempdir fallback is gone: it was chosen exactly when the local base is walked, and it is on the entry's filesystem only by luck. - With no qualifying candidate the park refuses BEFORE the first move. The rollback then fails closed: `ROLLBACK FAILED ...; snapshot preserved at ...`, naming the candidates it rejected and why, instead of a non-atomic half-move. `shutil.move` is out of this path entirely, so the comment no longer claims it is a safe equivalent for the crash window. - Root containment is compared on real paths: a root reached through a symlink (`/var` vs `/private/var`, a linked profile home) is the same directory to the loader's walk and must not be missed on a spelling mismatch. - Tests: a forced `EXDEV` now refuses with the entry untouched and asserts no copy is attempted (fails against the previous head, where the copy ran); a walked park base yields to a same-device ancestor instead of a tempdir; and a batch whose every candidate is off-device aborts, keeps its snapshot, and names the limitation. HERMES_HOME in that fixture moves one level below the temp dir so "the ancestor out" and the old tempdir fallback are distinguishable directories.
|
Landed on this branch as The cross-device fallback is gone rather than softened. Where no candidate qualifies, the park raises BEFORE the first move. The rollback for that skill then fails closed: Containment is compared on real paths, so a root reached through a symlink ( Regression, in the shape you asked for (
Verified: 8/8 in that file; 91/91 across This head is the reviewed head plus the one fix, so the exact-head review you ran stays applicable; the branch is still behind upstream |
What does this PR do?
A failed multi-op
skill_managebatch parks the half-applied skill inside the skillsroot while it copies the snapshot back (
_restore_snapshot,tools/skill_manager_batch.py:post_dir.with_name(post_dir.name + ".rollback-broken"), removed withshutil.rmtree(aside, ignore_errors=True)).That park is itself a skill:
agent.skill_utils.iter_skill_index_fileswalks a root withos.walk(followlinks=True)and prunes only
EXCLUDED_SKILL_DIRS, so a parked directory holdingSKILL.mdisoffered as a skill — with the live skill's frontmatter
name, so the name resolvestwice in one root. A process killed between the park and the copy-back (SIGKILL, host
restart, power cut) leaves it there for good, because nothing sweeps it.
shutil.rmtreerefuses a symlink, so when the entry is a directory symlink the parkcannot be removed at all —
ignore_errors=Trueswallowed the refusal and the linksurvived even the successful path.
This PR moves the park outside every skills root, removes it with a symlink-aware helper,
and sweeps parks older than a day.
Related Issue
Fixes #118691
Complementary to #112321 and #67748, not a duplicate of either: those fix which path
discovery returns and make snapshot/restore operate on the resolved link target. This
PR changes where the park lives — the two are independent, and they compose (with
either applied, the park becomes a real directory, but it is still parked inside the
skills root, so an interrupted restore still leaves a loadable shadow entry).
No files that #112321 touches are modified here.
Type of Change
Changes Made
tools/skill_manager_batch.py_restore_snapshotparks via the new_park_entry_asideand cleans up with_remove_path(symlink-aware) instead ofshutil.rmtree, which cannot delete asymlink.
_park_entry_asiderenames the entry into_asides_root()— a hidden.skill-rollback-asidesdirectory beside the skills root (same filesystem, so therename stays atomic;
tempfile.gettempdir()whenHERMES_HOMEis itself configuredas a skills root, so the park is never inside a walked tree) — and stamps the park's
own mtime so the stale check is about the park, not the entry.
_sweep_stale_asidesdrops parks older than_ASIDE_STALE_SECONDS(24h): a parkexists only between the rename and the copy-back, so anything that old was abandoned
by a process that died mid-restore.
tests/tools/test_skill_batch_rollback_park.py(new, 5 tests) — assertions use theloader's own walker (
iter_skill_index_files), so "nothing loadable was left behind"is checked the way the reader side sees it.
How to Test
python -m pytest -q tests/tools/test_skill_batch_rollback_park.py→ 4 failed, 1 passed, and on the same base tree
python -m pytest -q tests/tools/test_skill_manage_batch.py→ 9 passed (the deltais the new file). The one test that passes at base is the plain successful-rollback
path; it is a regression guard.
python -m pytest -q tests/tools/test_skill_batch_rollback_park.py tests/tools/test_skill_manage_batch.py→ 14 passed.
copy-back, then asserts the loader walk yields nothing under the root and exactly one
park exists outside it.
scripts/run_tests_parallel.py -j 8 tests/*/test_skill*.py tests/*/test_skills*.py→ 55 files, 883 passed, 0 failed, 9 skipped (macOS 26, Python 3.11). I ran this
skill-related surface rather than the full suite.
python -m ruff check tools/skill_manager_batch.py tests/tools/test_skill_batch_rollback_park.py→ clean.
Checklist
Code
fix(scope):)Documentation & Housekeeping
helpers explain why the park must not live inside a root; no user-facing docs
describe this path
cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Acompatibility guide —
the symlink test skips where directory symlinks are unavailable, and the kill test
asserts the platform-independent outcomes (−SIGKILL only on POSIX)
reported behaviour is unchanged: a failed batch still rolls every touched skill back)
🤖 Generated with Hermes Agent (AI-authored, test-first;
every claim in "How to Test" was run on this branch)