Skip to content

Commit d9370d9

Browse files
authored
fix: isolate per-extension failures so one bad extension can't drop the rest (#2951)
* fix: isolate per-extension failures in register_enabled_extensions_for_agent The per-extension loop had no error isolation: if registering one enabled extension raised (e.g. an OSError writing a command file), the loop aborted and the exception propagated, so every subsequent enabled extension was silently skipped. Callers wrap the whole call in a single best-effort try/except, so the wholesale abort surfaced as one warning while the command still exited 0 — leaving the agent with only a prefix of its extensions. Wrap the per-extension body in try/except: warn (naming the extension) and continue, so one bad extension can no longer drop the others. Add a regression test that forces the first-iterated extension to raise and asserts the rest still register. Closes #2950 * fix(extensions): preserve command registry when skills fail * fix: clarify skill registration warning
1 parent fd42fb1 commit d9370d9

2 files changed

Lines changed: 151 additions & 28 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,37 +1716,73 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
17161716
continue
17171717

17181718
ext_dir = self.extensions_dir / ext_id
1719-
updates: Dict[str, Any] = {}
17201719

1721-
if agent_config and not skills_mode_active:
1722-
registered = registrar.register_commands_for_agent(
1723-
agent_name, manifest, ext_dir, self.project_root
1724-
)
1725-
registered_commands = metadata.get("registered_commands", {})
1726-
if not isinstance(registered_commands, dict):
1727-
registered_commands = {}
1728-
new_registered = copy.deepcopy(registered_commands)
1729-
if registered:
1730-
new_registered[agent_name] = registered
1720+
# Isolate per-extension failures: one extension that fails to
1721+
# register (e.g. an OSError writing a command file) must not abort
1722+
# registration of the remaining enabled extensions for this agent.
1723+
try:
1724+
updates: Dict[str, Any] = {}
1725+
1726+
if agent_config and not skills_mode_active:
1727+
registered = registrar.register_commands_for_agent(
1728+
agent_name, manifest, ext_dir, self.project_root
1729+
)
1730+
registered_commands = metadata.get("registered_commands", {})
1731+
if not isinstance(registered_commands, dict):
1732+
registered_commands = {}
1733+
new_registered = copy.deepcopy(registered_commands)
1734+
if registered:
1735+
new_registered[agent_name] = registered
1736+
else:
1737+
# Registration returned empty list (e.g., corrupted
1738+
# manifest pointing at missing command files). Clear
1739+
# stale entry so later cleanup doesn't try to remove
1740+
# files that were never written.
1741+
new_registered.pop(agent_name, None)
1742+
if new_registered != registered_commands:
1743+
updates["registered_commands"] = new_registered
1744+
1745+
try:
1746+
registered_skills = self._register_extension_skills(manifest, ext_dir)
1747+
except Exception as skills_err:
1748+
# Skills are a companion artifact. If command registration
1749+
# already succeeded, still persist it so later cleanup can
1750+
# find those command files.
1751+
from . import _print_cli_warning
1752+
1753+
_print_cli_warning(
1754+
"register extension skills for",
1755+
"extension",
1756+
ext_id,
1757+
skills_err,
1758+
continuing=(
1759+
"Continuing with available registration results for this "
1760+
"extension and the remaining extensions."
1761+
),
1762+
)
17311763
else:
1732-
# Registration returned empty list (e.g., corrupted
1733-
# manifest pointing at missing command files). Clear
1734-
# stale entry so later cleanup doesn't try to remove
1735-
# files that were never written.
1736-
new_registered.pop(agent_name, None)
1737-
if new_registered != registered_commands:
1738-
updates["registered_commands"] = new_registered
1739-
1740-
registered_skills = self._register_extension_skills(manifest, ext_dir)
1741-
if registered_skills:
1742-
existing_skills = self._valid_name_list(
1743-
metadata.get("registered_skills", [])
1744-
)
1745-
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
1746-
updates["registered_skills"] = merged_skills
1764+
if registered_skills:
1765+
existing_skills = self._valid_name_list(
1766+
metadata.get("registered_skills", [])
1767+
)
1768+
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
1769+
updates["registered_skills"] = merged_skills
17471770

1748-
if updates:
1749-
self.registry.update(ext_id, updates)
1771+
if updates:
1772+
self.registry.update(ext_id, updates)
1773+
except Exception as ext_err:
1774+
# Best-effort per extension: warn and move on so a single bad
1775+
# extension cannot silently drop the others. See #2950.
1776+
from . import _print_cli_warning
1777+
1778+
_print_cli_warning(
1779+
"register extension artifacts for",
1780+
"extension",
1781+
ext_id,
1782+
ext_err,
1783+
continuing="Continuing with the remaining extensions.",
1784+
)
1785+
continue
17501786

17511787
def list_installed(self) -> List[Dict[str, Any]]:
17521788
"""List all installed extensions with metadata.

tests/test_extension_skills.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,93 @@ def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration(
10361036
assert metadata["registered_skills"] == []
10371037
assert (project_dir / ".github" / "agents").is_dir()
10381038

1039+
def test_one_failing_extension_does_not_abort_the_rest(
1040+
self, project_dir, temp_dir, monkeypatch
1041+
):
1042+
"""A single failing extension must not block registration of the others.
1043+
1044+
Regression for #2950: ``register_enabled_extensions_for_agent`` iterates
1045+
enabled extensions; before the per-extension isolation, the first one
1046+
that raised (e.g. an OSError writing a command file) aborted the loop and
1047+
the exception propagated, so every later extension was silently skipped.
1048+
"""
1049+
from specify_cli.extensions import CommandRegistrar
1050+
1051+
_create_init_options(project_dir, ai="claude", ai_skills=False)
1052+
manager = ExtensionManager(project_dir)
1053+
# Two enabled extensions; the first one iterated ("aaa-fail") will raise.
1054+
manager.install_from_directory(
1055+
_create_extension_dir(temp_dir, ext_id="aaa-fail"), "0.1.0",
1056+
register_commands=False,
1057+
)
1058+
manager.install_from_directory(
1059+
_create_extension_dir(temp_dir, ext_id="bbb-ok"), "0.1.0",
1060+
register_commands=False,
1061+
)
1062+
1063+
original = CommandRegistrar.register_commands_for_agent
1064+
1065+
def flaky(self, agent_name, manifest, ext_dir, project_root, link_outputs=False):
1066+
if manifest.id == "aaa-fail":
1067+
raise OSError("simulated command-file write failure")
1068+
return original(
1069+
self, agent_name, manifest, ext_dir, project_root,
1070+
link_outputs=link_outputs,
1071+
)
1072+
1073+
monkeypatch.setattr(CommandRegistrar, "register_commands_for_agent", flaky)
1074+
1075+
# Must not propagate, despite the first extension failing.
1076+
manager.register_enabled_extensions_for_agent("claude")
1077+
1078+
# The healthy extension was still registered for the agent...
1079+
ok_meta = manager.registry.get("bbb-ok")
1080+
assert "claude" in ok_meta["registered_commands"], (
1081+
"a later extension must still register after an earlier one fails (#2950)"
1082+
)
1083+
# ...and the failing one was not.
1084+
fail_meta = manager.registry.get("aaa-fail")
1085+
assert "claude" not in fail_meta.get("registered_commands", {})
1086+
1087+
def test_skill_registration_failure_preserves_registered_commands(
1088+
self, project_dir, temp_dir, monkeypatch, capsys
1089+
):
1090+
"""Persist successful command registration even if skills fail.
1091+
1092+
If command files are written but skill generation raises, the command
1093+
registry must still be updated so later unregister/cleanup can find the
1094+
command files.
1095+
"""
1096+
_create_init_options(project_dir, ai="claude", ai_skills=False)
1097+
manager = ExtensionManager(project_dir)
1098+
manager.install_from_directory(
1099+
_create_extension_dir(temp_dir, ext_id="skill-fail"), "0.1.0",
1100+
register_commands=False,
1101+
)
1102+
1103+
def fail_skills(self, manifest, ext_dir, link_outputs=False):
1104+
raise OSError("simulated skill directory failure")
1105+
1106+
monkeypatch.setattr(
1107+
ExtensionManager, "_register_extension_skills", fail_skills
1108+
)
1109+
1110+
manager.register_enabled_extensions_for_agent("claude")
1111+
1112+
metadata = manager.registry.get("skill-fail")
1113+
assert metadata is not None
1114+
assert metadata["registered_commands"] == {
1115+
"claude": [
1116+
"speckit.skill-fail.hello",
1117+
"speckit.skill-fail.world",
1118+
]
1119+
}
1120+
assert metadata["registered_skills"] == []
1121+
1122+
captured = capsys.readouterr()
1123+
assert "register extension skills for extension 'skill-fail'" in captured.out
1124+
assert "Continuing with available registration results" in captured.out
1125+
10391126
def test_existing_agent_command_path_file_is_not_detected(
10401127
self, project_dir, temp_dir
10411128
):

0 commit comments

Comments
 (0)