fix(store): prune ghost rows left by disabling then deleting a skill - #128
Merged
Conversation
Disabling a skill renames SKILL.md to SKILL.md.disabled, and the scanner
reports the renamed file with enabled=false β so a disabled skill still
on disk appears in every scan and never looks stale. The blanket
`if !enabled { return false }` guard therefore did nothing for it, and
only fired once the .disabled file was gone too: exactly the case where
the row should go. Deleting a disabled skill outside HarnessKit left a
row no rescan could clear, and clicking "enable" on it silently did
nothing while flipping the toggle on, so the list claimed an active
skill that did not exist.
Narrow the guard to rows whose disabled state the store alone holds,
which is what `disabled_config` marks. Skills never set it β disabling
one leaves its bytes on disk under another name β so they become
collectable, while rows that would lose their only copy stay exempt.
UPSERT_EXTENSION_SQL already uses the same predicate to stop a scan from
overwriting `enabled`, so this keeps one notion of "the store owns this
row" rather than two.
Also stop reporting every disabled file-backed row as missing. The
scanner deliberately records the *enabled* filename for a disabled skill
so its id survives toggling, so testing source_path alone always failed
and the transient-scan-gap exemption never reached disabled rows. Check
the .disabled sibling too.
Verified against a real store: the ghost row cleared on the next scan,
and all 85 disabled rows with a source path still resolved on disk and
were left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Disable a skill in HarnessKit, then delete its folder outside the app. The row
never goes away β no number of rescans clears it β and the app keeps offering an
extension that no longer exists.
Worse, the one thing a user might try makes it lie.
toggle_skill's enable armis
if disabled_file.exists() { rename }; with the file gone the branch simplydoes not run, the function returns
Ok, and the caller flipsenabledto trueanyway. No error, no toast β the list now shows an active skill backed by
nothing. (That also happens to be the only way to clear the row today: once
enabledis true the guard below stops protecting it and the next scan collectsit. Nobody would ever find that.)
Why the old guard missed it
stale_row_should_pruneopened with:That premise does not hold for skills. Disabling one renames
SKILL.mdtoSKILL.md.disabled, and the scanner looks for both names and reports therenamed file with
enabled = false(scanner::scan_skill_dir). A disabledskill that is still on disk is therefore in every scan and is never stale β the
guard never applies to it.
The only way a disabled skill reaches that guard is if
SKILL.md.disabledisgone too. Which is precisely when the row should be collected. The guard was
firing exclusively in the case it was meant to exclude.
The fix
Narrow it to rows whose disabled state the store alone holds β what
disabled_configmarks:Skills never set that field; disabling one leaves the bytes on disk under
another name, so there is nothing in the store to lose and they become
collectable. Rows that would lose their only copy stay exempt, and most of
them carry no
install_meta, so nothing further down would have saved them.This is the same predicate
UPSERT_EXTENSION_SQLalready uses to stop a scanfrom overwriting
enabled, so the store keeps one notion of "this row's stateis mine" instead of two that can drift apart.
A second, quieter bug
For a disabled skill the scanner deliberately records the enabled filename
in
source_pathso the row's id survives toggling. SoPath::new(source_path).exists()is false for every disabled skill, and the"file is still there, this was just a scan gap" exemption never reached them β
they had no protection against a transient scan failure at all.
source_still_on_disknow checks the
.disabledsibling too.Scope
Only skills change behaviour. Nothing else newly qualifies for pruning.
Verification
Unit:
stale_row_should_prune's truth table gained the disabled cases; theghost-row test covers a disabled skill deleted (collected) and a disabled skill
still present as
.disabled(kept); the per-agent test gained a row carryingdisabled_configto pin the new column through that query's own SELECT list.test_sync_preserves_disabled_extensionsnow setsdisabled_configthe waymanager::toggle_mcpdoes β it was passing on the blanket guard rather than onthe invariant it claims to assert.
On a real store, through the desktop app: a probe skill disabled, deleted from
disk, and cleared on the next focus-triggered scan β including a second row for
the same file reached through a symlinked agent skills directory. Of the 85
remaining disabled rows carrying a source path, every one still resolves on disk
and none were collected.
cargo test -p hk-core: 708 + 16 passing.