Skip to content

fix(store): prune ghost rows left by disabling then deleting a skill - #128

Merged
RealZST merged 1 commit into
mainfrom
fix/stale-disabled-extension-rows
Aug 28, 2026
Merged

fix(store): prune ghost rows left by disabling then deleting a skill#128
RealZST merged 1 commit into
mainfrom
fix/stale-disabled-extension-rows

Conversation

@tshi92

@tshi92 tshi92 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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 arm
is if disabled_file.exists() { rename }; with the file gone the branch simply
does not run, the function returns Ok, and the caller flips enabled to true
anyway. 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
enabled is true the guard below stops protecting it and the next scan collects
it. Nobody would ever find that.)

Why the old guard missed it

stale_row_should_prune opened with:

if !enabled {
    return false;   // "disabled rows are intentionally absent from scans"
}

That premise does not hold for skills. Disabling one renames SKILL.md to
SKILL.md.disabled, and the scanner looks for both names and reports the
renamed file with enabled = false (scanner::scan_skill_dir). A disabled
skill 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.disabled is
gone 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_config marks:

if !enabled && has_disabled_config {
    return false;
}

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_SQL already uses to stop a scan
from overwriting enabled, so the store keeps one notion of "this row's state
is 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_path so the row's id survives toggling. So
Path::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_disk
now checks the .disabled sibling 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; the
ghost-row test covers a disabled skill deleted (collected) and a disabled skill
still present as .disabled (kept); the per-agent test gained a row carrying
disabled_config to pin the new column through that query's own SELECT list.
test_sync_preserves_disabled_extensions now sets disabled_config the way
manager::toggle_mcp does β€” it was passing on the blanket guard rather than on
the 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.

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>
@RealZST
RealZST merged commit 76bf923 into main Aug 28, 2026
3 checks passed
@RealZST
RealZST deleted the fix/stale-disabled-extension-rows branch August 28, 2026 15:41
@RealZST RealZST mentioned this pull request Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants