fix(cli): prevent import deadlock during skill discovery and prewarm (#3385)

Resolves a race condition where skill discovery and server startup could
simultaneously cold-import overlapping parts of the Deep Agents module
graph, causing CPython's per-module import locks to deadlock. Adds
graceful degradation for malformed or partially-written skill files so
one bad source doesn't block all others.

## Changes

- **Serialized cold imports** with an `asyncio.Lock`
(`_deepagents_import_lock`) held across skill discovery thread offload
and model creation startup, ensuring prewarm completes before either
path re-enters the graph
- **Ordered startup sequence** so `_discover_skills` explicitly awaits
prewarm imports before spawning the discovery thread
- **Broadened per-source error handling** in `list_skills` from
`(OSError, KeyError, TypeError)` to `Exception`, degrading gracefully
when a single skill source is malformed or unreadable
- **Added debug logging bootstrap** (`configure_debug_logging`) and
richer user-facing warnings that include the exception type and a
`DEEPAGENTS_CLI_DEBUG=1` hint
- **Added targeted regression tests** enforcing the prewarm→discovery
call order, import lock gating during server startup, and failure
isolation across skill sources
This commit is contained in:
Mason Daugherty
2026-05-12 11:59:37 -07:00
committed by GitHub
parent a5da793a78
commit 0bf8dd1cfd
4 changed files with 160 additions and 6 deletions
+22 -5
View File
@@ -43,6 +43,7 @@ from deepagents_cli import (
)
from deepagents_cli._cli_context import CLIContext
from deepagents_cli._constants import DEFAULT_AGENT_NAME as DEFAULT_ASSISTANT_ID
from deepagents_cli._debug import configure_debug_logging
from deepagents_cli._git import (
read_git_branch_from_filesystem,
read_git_branch_via_subprocess,
@@ -90,6 +91,7 @@ from deepagents_cli.widgets.status import StatusBar
from deepagents_cli.widgets.welcome import WelcomeBanner
logger = logging.getLogger(__name__)
configure_debug_logging(logger)
_monotonic = time.monotonic
_DEFERRED_START_NOTICE = (
@@ -1388,6 +1390,14 @@ class DeepAgentsApp(App):
loop re-enters the same module graph (see that method for why).
"""
self._deepagents_import_lock = asyncio.Lock()
"""Serializes cold imports into the Deep Agents graph after prewarm.
Startup model creation and skill discovery can both be triggered during
first paint. If prewarm failed or did not cover a transitive module,
both paths may cold-import overlapping modules at the same time.
"""
# Lifecycle flags & re-entry guards
self._connecting = (
server_kwargs is not None and not self._server_startup_deferred
@@ -2144,7 +2154,12 @@ class DeepAgentsApp(App):
from deepagents_cli.command_registry import SLASH_COMMANDS, build_skill_commands
try:
skills, roots = await asyncio.to_thread(self._discover_skills_and_roots)
# Discovery and prewarm import overlapping parts of the Deep Agents
# graph in separate workers. Let prewarm finish first so CPython's
# per-module import locks cannot form a cycle.
await self._await_prewarm_imports()
async with self._deepagents_import_lock:
skills, roots = await asyncio.to_thread(self._discover_skills_and_roots)
except OSError:
logger.warning(
"Filesystem error during skill discovery",
@@ -2158,11 +2173,12 @@ class DeepAgentsApp(App):
markup=False,
)
return False
except Exception:
except Exception as exc:
logger.exception("Unexpected error during skill discovery")
self.notify(
"Skill discovery failed unexpectedly. "
"/skill: commands may not work. Check logs for details.",
f"Skill discovery failed unexpectedly ({type(exc).__name__}). "
"/skill: commands may not work. "
"Set DEEPAGENTS_CLI_DEBUG=1 for details.",
severity="warning",
timeout=8,
markup=False,
@@ -2332,7 +2348,8 @@ class DeepAgentsApp(App):
from deepagents_cli.model_config import ModelConfigError, save_recent_model
try:
result = create_model(**self._model_kwargs)
async with self._deepagents_import_lock:
result = create_model(**self._model_kwargs)
except ModelConfigError as exc:
self.post_message(self.ServerStartFailed(error=exc))
return
+5 -1
View File
@@ -123,7 +123,11 @@ def list_skills(
}
extended = cast("ExtendedSkillMetadata", {**skill, **extra})
all_skills[skill["name"]] = extended
except (OSError, KeyError, TypeError):
except Exception:
# Degrade gracefully — one malformed/inaccessible source must not
# block discovery of others, so catch broadly and log instead.
# WARNING (not ERROR) because a half-written SKILL.md from a user is
# an expected condition, not a code defect.
logger.warning(
"Could not load skills from %s",
skill_dir,
@@ -719,6 +719,40 @@ class TestListSkillsBuiltIn:
assert len(skills) == 1
assert skills[0]["name"] == "user-skill"
def test_unexpected_source_error_does_not_break_others(
self, tmp_path: Path
) -> None:
"""Any per-source discovery error should preserve remaining sources."""
user_dir = tmp_path / "user_skills"
_create_skill(user_dir / "user-skill", "user-skill", "A user skill")
built_in_dir = tmp_path / "built_in_skills"
built_in_dir.mkdir()
original_list = __import__(
"deepagents.middleware.skills", fromlist=["_list_skills"]
)._list_skills
call_count = 0
def patched_list(backend: object, source_path: str) -> list[object]:
nonlocal call_count
call_count += 1
if call_count == 1:
msg = "simulated parser edge case"
raise ValueError(msg)
return original_list(backend=backend, source_path=source_path)
with patch("deepagents_cli.skills.load.list_skills_from_backend", patched_list):
skills = list_skills(
built_in_skills_dir=built_in_dir,
user_skills_dir=user_dir,
project_skills_dir=None,
)
assert len(skills) == 1
assert skills[0]["name"] == "user-skill"
class TestListSkillsClaudeDirectories:
"""Test `list_skills` with experimental Claude skills directories."""
+99
View File
@@ -7771,6 +7771,105 @@ class TestPrewarmAwait:
for notify_call in warning_calls:
assert notify_call.kwargs.get("markup") is False
async def test_discover_skills_awaits_prewarm_before_thread_offload(
self,
) -> None:
"""Locks the call-order invariant that prevents an import deadlock.
Skill discovery and prewarm import overlapping parts of the Deep Agents
graph in separate workers. A regression that drops the
`await _await_prewarm_imports()` re-introduces the production crash;
this is the only test that catches that ordering contract.
"""
call_order: list[str] = []
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
async def record_prewarm() -> None:
call_order.append("prewarm")
await asyncio.sleep(0)
def record_discover() -> tuple[list[Any], list[Any]]:
call_order.append("discover")
return [], []
with (
patch.object(app, "_await_prewarm_imports", side_effect=record_prewarm),
patch.object(
app, "_discover_skills_and_roots", side_effect=record_discover
),
):
await app._discover_skills()
assert call_order == ["prewarm", "discover"], (
f"prewarm must precede skill discovery thread; got {call_order}"
)
async def test_discover_skills_prewarm_failure_warns_with_debug_hint(
self,
) -> None:
"""A prewarm failure should use the discovery failure toast path."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
with (
patch.object(
app,
"_await_prewarm_imports",
AsyncMock(side_effect=RuntimeError("prewarm failed")),
),
patch.object(app, "_discover_skills_and_roots") as discover_mock,
patch.object(app, "notify") as notify_mock,
):
ok = await app._discover_skills()
assert ok is False
discover_mock.assert_not_called()
notify_mock.assert_called_once()
message = notify_mock.call_args.args[0]
assert "RuntimeError" in message
assert "DEEPAGENTS_CLI_DEBUG=1" in message
assert notify_mock.call_args.kwargs["severity"] == "warning"
assert notify_mock.call_args.kwargs["markup"] is False
async def test_start_server_waits_for_skill_discovery_import_gate(
self,
) -> None:
"""Startup model creation must not import during skill discovery."""
from deepagents_cli import config as cli_config
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-7"}
app._server_kwargs = None
app._mcp_preload_kwargs = None
app._resume_thread_intent = None
app._assistant_id = None
await app._deepagents_import_lock.acquire()
def fake_create_model(**_: Any) -> MagicMock:
result = MagicMock()
result.apply_to_settings = MagicMock()
result.provider = "anthropic"
result.model_name = "claude-opus-4-7"
return result
with (
patch.object(app, "_await_prewarm_imports", AsyncMock()),
patch.object(
cli_config, "create_model", side_effect=fake_create_model
) as create_model_mock,
patch("deepagents_cli.model_config.save_recent_model"),
patch.object(app, "post_message"),
):
task = asyncio.create_task(app._start_server_background())
await asyncio.sleep(0)
assert create_model_mock.call_count == 0
app._deepagents_import_lock.release()
with contextlib.suppress(Exception):
await task
create_model_mock.assert_called_once()
class TestHeaderAndTitle:
"""Header widget visibility and custom title overrides."""