Skip to content

Commit d0d152e

Browse files
marcelsafinCopilot
andcommitted
Verify replacement actually landed before retiring stale toggle artifacts
The command<->skills toggle cleanup added for #2948 deferred destructive removal of the old-mode artifact until after the replacement registration call completed without raising. That was necessary but not sufficient: none of _register_skills(), _register_commands(), register_commands_for_agent(), or _register_extension_skills() raise on a missing source template, a safety-validation skip, or a corrupted manifest entry — they simply return an empty or partial result. Treating "did not raise" as "fully replaced" meant a stale artifact could still be deleted (or its tracking dropped) even though its specific replacement never actually landed, leaving neither artifact in place for that logical command/skill. Fix all four affected toggle directions by checking the replacement call's actual return value before allowing any destructive step: - presets command->skills (register_enabled_presets_for_agent): only unregister a stale command name once its corresponding skill name (via the existing _skill_names_for_command() helper) is confirmed present in the skills call's returned names for that agent; the remainder stays tracked and on disk. - presets skills->command (register_enabled_presets_for_agent): only unregister a stale skill name once its corresponding command name is confirmed present in the commands call's returned names for that agent, using the same helper. - extensions skills->command (register_enabled_extensions_for_agent): only remove a skill mirror once the matching command (mapped via the existing HookExecutor._skill_name_from_command() helper) is confirmed present in register_commands_for_agent's returned names. - extensions command->skills (register_enabled_extensions_for_agent): only remove a deferred stale command once its matching skill name is confirmed present in _register_extension_skills()'s returned names. All four reuse the existing command<->skill name-derivation helpers rather than inventing new mapping logic. Registry tracking is updated to retain exactly the unreplaced subset rather than being popped wholesale, so partially-successful toggles leave correct, minimal tracking behind. Added 8 new regression tests (4 presets, 4 extensions) covering both the fully-empty and genuinely-partial result cases for each of the four toggle directions, using real missing-source-file scenarios (not mocked return values) to exercise the actual code paths. Confirmed red before the fix and green after for all 8. Focused (test_presets.py, test_extension_skills.py, test_extensions.py, tests/integrations): 2551 passed, 1 skipped. Full suite (tests, excluding the pre-existing environment-local 1Password-signing git-extension failures): 3917 passed, 74 skipped, 90 deselected. ruff check: clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f19225a commit d0d152e

4 files changed

Lines changed: 607 additions & 24 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,9 +2103,26 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
21032103
for name in existing_skills
21042104
if (agent_skills_dir / name).is_dir()
21052105
]
2106-
if owned_here:
2106+
# Only retire a skill mirror when the
2107+
# replacement command for the same logical
2108+
# command was actually written this call —
2109+
# `registered` (from register_commands_for_agent
2110+
# above) may be empty or a partial subset
2111+
# (missing source file, safety rejection,
2112+
# corrupted manifest), and removing a skill
2113+
# mirror whose command replacement never
2114+
# landed would leave neither artifact (#2948).
2115+
replaced_skill_names = {
2116+
HookExecutor._skill_name_from_command(cmd_name)
2117+
for cmd_name in (registered or [])
2118+
}
2119+
to_remove = [
2120+
name for name in owned_here
2121+
if name in replaced_skill_names
2122+
]
2123+
if to_remove:
21072124
self._unregister_extension_skills(
2108-
owned_here, ext_id, skills_dir=agent_skills_dir
2125+
to_remove, ext_id, skills_dir=agent_skills_dir
21092126
)
21102127
# registered_skills is a single flat list
21112128
# shared across every agent this extension
@@ -2132,17 +2149,41 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
21322149
# registration is confirmed, so the stale
21332150
# command-mode artifact can finally be removed
21342151
# without risking a transient state where neither
2135-
# artifact exists (#2948).
2152+
# artifact exists. Only retire a stale command
2153+
# whose corresponding skill was actually returned
2154+
# this call — `registered_skills` may be empty or
2155+
# a partial subset (missing source file, safety
2156+
# rejection, corrupted manifest), and unregistering
2157+
# a command whose skill replacement never landed
2158+
# would leave neither artifact (#2948).
21362159
if deferred_stale_commands:
2137-
registrar.unregister_commands(
2138-
{agent_name: deferred_stale_commands}, self.project_root
2139-
)
2140-
registered_commands = metadata.get("registered_commands", {})
2141-
if isinstance(registered_commands, dict):
2142-
new_registered = copy.deepcopy(registered_commands)
2143-
new_registered.pop(agent_name, None)
2144-
if new_registered != registered_commands:
2145-
updates["registered_commands"] = new_registered
2160+
replaced_skill_names = set(registered_skills or [])
2161+
fully_replaced = [
2162+
cmd_name for cmd_name in deferred_stale_commands
2163+
if HookExecutor._skill_name_from_command(cmd_name)
2164+
in replaced_skill_names
2165+
]
2166+
if fully_replaced:
2167+
registrar.unregister_commands(
2168+
{agent_name: fully_replaced}, self.project_root
2169+
)
2170+
registered_commands = metadata.get(
2171+
"registered_commands", {}
2172+
)
2173+
if isinstance(registered_commands, dict) and (
2174+
registered_commands.get(agent_name)
2175+
):
2176+
new_registered = copy.deepcopy(registered_commands)
2177+
remaining_commands = [
2178+
c for c in new_registered[agent_name]
2179+
if c not in fully_replaced
2180+
]
2181+
if remaining_commands:
2182+
new_registered[agent_name] = remaining_commands
2183+
else:
2184+
new_registered.pop(agent_name, None)
2185+
if new_registered != registered_commands:
2186+
updates["registered_commands"] = new_registered
21462187

21472188
if updates:
21482189
self.registry.update(ext_id, updates)

src/specify_cli/presets/__init__.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -854,11 +854,28 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None:
854854
# direction is already register-new-then-remove-old:
855855
# _register_commands (the replacement) ran unconditionally
856856
# above and only reaches here once it has already
857-
# succeeded, so this cleanup happens only after the new
858-
# command artifact is confirmed in place (#2948).
859-
self._unregister_skills(
860-
{agent_name: merged_skills.pop(agent_name)}, pack_dir
861-
)
857+
# succeeded — but that call can still have returned
858+
# empty or partial results (missing source template,
859+
# safety-validation skip, corrupted manifest), so only
860+
# retire the subset of stale skills whose corresponding
861+
# command name was actually returned for this agent;
862+
# anything unreplaced stays tracked and on disk (#2948).
863+
stale_skill_names = merged_skills[agent_name]
864+
replaced_skill_names: set = set()
865+
for cmd_name in registered_commands.get(agent_name) or []:
866+
modern_name, legacy_name = self._skill_names_for_command(cmd_name)
867+
replaced_skill_names.add(modern_name)
868+
replaced_skill_names.add(legacy_name)
869+
to_remove = [n for n in stale_skill_names if n in replaced_skill_names]
870+
remaining_stale = [
871+
n for n in stale_skill_names if n not in replaced_skill_names
872+
]
873+
if to_remove:
874+
self._unregister_skills({agent_name: to_remove}, pack_dir)
875+
if remaining_stale:
876+
merged_skills[agent_name] = remaining_stale
877+
else:
878+
merged_skills.pop(agent_name, None)
862879
# A legacy flat-list registered_skills value (predating
863880
# per-agent provenance) must migrate to the dict format on
864881
# disk even when the rescaffolded names are unchanged from
@@ -874,14 +891,32 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None:
874891
if merged_skills != existing_skills or needs_migration:
875892
self.registry.update(pack_id, {"registered_skills": merged_skills})
876893

877-
# The skills phase above completed without raising, so the
878-
# replacement artifact is confirmed — now it's safe to
879-
# remove the stale command-mode artifact deferred earlier
880-
# (#2948).
894+
# The skills phase above completed without raising, but a
895+
# non-raising result can still be empty or partial (missing
896+
# source template, safety-validation skip, corrupted
897+
# manifest) — retiring every stale command purely on "did
898+
# not raise" would delete a command whose replacement skill
899+
# never actually landed, leaving neither artifact. Only
900+
# retire the subset of stale commands whose corresponding
901+
# skill name was actually returned for this agent; anything
902+
# unreplaced stays tracked and on disk (#2948).
881903
if stale_command_names:
882-
self._unregister_commands({agent_name: stale_command_names})
883-
merged_commands.pop(agent_name, None)
884-
self.registry.update(pack_id, {"registered_commands": merged_commands})
904+
replaced_skill_names = set(registered_skills.get(agent_name) or [])
905+
fully_replaced = []
906+
remaining_stale = []
907+
for cmd_name in stale_command_names:
908+
modern_name, legacy_name = self._skill_names_for_command(cmd_name)
909+
if modern_name in replaced_skill_names or legacy_name in replaced_skill_names:
910+
fully_replaced.append(cmd_name)
911+
else:
912+
remaining_stale.append(cmd_name)
913+
if fully_replaced:
914+
self._unregister_commands({agent_name: fully_replaced})
915+
if remaining_stale:
916+
merged_commands[agent_name] = remaining_stale
917+
else:
918+
merged_commands.pop(agent_name, None)
919+
self.registry.update(pack_id, {"registered_commands": merged_commands})
885920
except Exception as pack_err:
886921
from .. import _print_cli_warning
887922

0 commit comments

Comments
 (0)