mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user