Repository navigation
fix(skills): keep a newer skills ref and a removed tool when a refresh overlaps - #126
dg-coreylweathers wants to merge 2 commits into
Conversation
a161ef0 to
c2efdbd
Compare
GregHolmes
left a comment
There was a problem hiding this comment.
Title: [B1] packages/deepctl-core/src/deepctl_core/skill_generator.py:632-636 A moving ref can still be overwritten by an older refresh
Summary: The new recheck compares only the ref string. User refs are deliberately mutable and always re-downloaded (packages/deepctl-core/src/deepctl_core/skill_bundle.py:171-176), so two overlapping refreshes of the same branch name can fetch different commits. If the newer download installs first, the older download sees the same string in skills_ref, passes the guard, and replaces the newer folders with stale content. This is the stale-refresh class this PR is intended to eliminate, just without a changed ref spelling.
Expected: A non-explicit refresh that fetched an older resolution of a moving ref must never replace content installed by a later refresh of that same ref.
Observed: For both runs using a ref such as main, _recorded(state, cli) == _recorded(since, cli) == ref; the guard treats the record as unchanged and the older download proceeds.
Recommended fix: Recheck a stable snapshot of the full tool record, not only skills_ref, before staging or moving. A non-explicit refresh should skip when that record changed, including when the ref spelling stayed the same; preserve the existing E31 behavior for a removed record and the --ref override policy. Add a race test where two refreshes use one moving ref but receive different bundle bodies, assert the newer body remains installed, and cover plugin refresh plus dg skills update.
c2efdbd to
b92df08
Compare
b92df08 to
b0b1595
Compare
|
On B1 (moving refs): fixed in 1f86cd0 and b0b1595. Under the lock, |
Stacked on #125 until it merges.
What
Fixes S5 from the #111 review: a concurrent refresh could overwrite a newer explicit skills ref. It also covers the related case where a refresh that started before
dg skills removeputs the removed tool's skills back.dg skills updateand the plugin refresh choose each tool's ref fromskills.json, then download the bundle with no lock held. While that download runs, another command can install a tool from a different ref or remove it. Before this change, the refresh then wrote over that newer state.How
skills.jsonit planned from (since) toinstall_for, which passes it toinstall_tool.install_toolalready holds theskills.jsonlock for the whole install. Right after it reads the records under that lock, and before it stages, marks or moves anything, it compares the tool's whole record with the planned one (_recorded: the tool's fullskill_foldersrecord, includingskills_ref,version,installed_atand every folder's fingerprint, plus itsinstalled_skillsentry, or nothing when the tool is not listed):--refto thisdg skills update. The warning is E30 when the record now names a different ref, and E32 otherwise (the same ref, which may be a moving branch, or no ref). An explicit--refstill wins, and the last explicit command wins, but it never brings back a removed tool. Sodg skills update --ref <moving ref>applies its ref even if its download is older than one another command installed meanwhile.SkillSkipped(aSkillInstallError).install_forcatches it per tool, prints one warning on stderr, does not yield that tool and carries on with the next one. Exit codes and the yielded tuples are unchanged.dg skills install,dg skills setup, the first-time prompt indg login) pass no snapshot and keep their current behavior.DEEPCTL_SKILLS_REFcounts as an implicit ref.Messages
Another deepctl command installed {display}'s skills from a different ref while this one was running, so deepctl kept that ref and did not update {display}.Another deepctl command removed {display}'s skills while this one was running, so deepctl did not install them again.Another deepctl command changed {display}'s skills while this one was running, so deepctl left them as that command left them and did not update {display}; run 'dg skills update' to refresh them.It fires on any change to the tool's record, including one left by another command that failed partway.Interleavings covered
dg skills remove(during the download or between tools): the tool is skipped with E31 and nothing is written for it.dg skills removewaits on the lock while the refresh installs that tool: the remove then removes the fresh copy.dg skills install --ref X: the tool is skipped with E30 and X stays.dg skills update --ref Yoverlapsdg skills install --ref X: Y is applied (both are explicit); a tool removed meanwhile is still skipped.main) that downloaded different commits: whichever installs first stays, and the other is skipped with E32.Tests
test_refresh_never_overwrites_a_ref_changed_during_its_downloadtest_refresh_never_overwrites_a_newer_copy_of_the_same_ref(core),test_refresh_keeps_a_newer_copy_of_the_same_moving_ref(plugin) andtest_update_keeps_a_newer_copy_of_the_same_moving_ref(dg skills update). Each gives two refreshes one ref but different bundle bodies, lands the newer one first, and asserts the newer body and record stay with one E32 and no E30.test_refresh_never_reinstalls_a_tool_removed_during_its_downloadTestRecheckUnderLock: the skip is per tool; an explicit ref still wins but never brings back a removed tool; fresh installs ignore the records; 0.3.x-only tools still get folders (and are skipped with E31 when removed); two refreshes of a blank-ref or 0.3.x-only record keep the first install and warn with E32, never E30 (test_two_refreshes_of_a_blank_ref_record_keep_the_first); Ctrl-C at the check writes nothing.test_fetch_runs_without_the_locknow runs with and without a snapshot.dg skills update:test_update_keeps_a_ref_installed_during_its_download(exit 0, E30 on stderr),test_update_with_ref_overrides_but_skips_a_removed_tool, andtest_update_summary_counts_only_tools_it_installed(the closing summary lists only the refs of tools it installed, and the/setup-mcphint appears only when Claude Code was installed).test_refresh_skips_a_tool_removed_during_its_download(stdout empty, one E31 warning) andtest_refresh_keeps_an_explicit_ref_set_during_its_download(exit 0, one E30 warning).--ref, comparing the planned ref instead of the record, treating a skip as a failure) each fail at least two tests.DEEPCTL_SKILLS_REF, and a second refresh of a blank-ref record, are each skipped with exactly one E32 and leave the other command's files and record byte for byte; a different env ref gets E30;update --ref Yoverinstall --ref Ygoes ahead with no warning); remove andinstall --refoverlapping a plugin refresh anddg skills update, during the download and between tools (8 of 8 pass); the remove/refresh orderings for plugin, update, login and install, plus a second plugin refresh in place of the remove, including a remove that finishes during the download (30 of 30 with no orphans or leftover staging); and a HOME written bymain'sdg skills install --all, refreshed by the plugin and bydg skills updatein both orders, which installs every folder tool with no E30 or E31.Not in this PR
A pending-removal state, keep-going past a failed tool, and pruning of retired skills.
Budget and gate
skill_generator.py+56/-6,deepctl_cmd_skills/command.py+11/-8,deepctl_cmd_plugin/command.py+1/-1,README.md+1/-1.python3.12-bookworm-slim): ruff format, ruff check, mypy and the full suite: 1991 passed, 9 skipped. Core tests as uid 1000: 728 passed, 2 skipped. Core tests on Python 3.10: 727 passed, 3 skipped.Review response
skills_ref, so a non-explicit refresh skips a tool whose record changed at all, including under the same ref spelling, and warns with the new E32. E31 for a removed tool and the--refoverride are unchanged. Race tests for core, the plugin refresh anddg skills updatecover it, and the earlier blank-ref case now prints the true E32 instead of installing silently.🤖 Generated with Claude Code