fix(code): defer server graph construction (#4300)

Import-only checks were constructing the server graph, which let runtime
startup behavior leak into local validation and touch a developer’s real
dcode config. The server graph is now exposed as a LangGraph factory so
MCP discovery runs only when the server actually builds the graph, and
the import checker runs against an isolated home directory.

## Changes

- Switch the generated LangGraph reference from `server_graph.py:graph`
to `server_graph.py:make_graph`, relying on LangGraph’s factory support
instead of constructing the graph at module import time.
- Keep startup error marker behavior inside `make_graph()` so server
startup failures still surface cleanly to the parent process, while
plain imports remain side-effect free.
- Run `check_imports.py` with a temporary `HOME` so import validation
cannot read or depend on local `~/.deepagents` config, MCP auth tokens,
or other user state.
- Update server graph tests to assert MCP discovery does not happen on
import and still happens when `make_graph()` is invoked.
- Override `UV_FROZEN` only for the `uv lock --check` command so `make
check` performs the real lockfile freshness check without warning.

## Testing

- `make -C libs/code check PYTHON_FILES=
PYTEST_EXTRA="tests/unit_tests/test_server_graph.py
tests/unit_tests/test_server_manager.py -q" COV_ARGS=`
- `make -C libs/code check_imports`
- Focused ruff, ty, and pytest checks for the touched server
graph/server manager files
This commit is contained in:
Mason Daugherty
2026-06-26 00:11:29 -04:00
committed by GitHub
parent 92a86819ad
commit 220dfc0e6b
8 changed files with 367 additions and 61 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ check: lint check_imports test
@echo "🔍 Verifying pyproject.toml and _version.py agree..."
uv run --all-groups python "$(REPO_ROOT)/.github/scripts/check_version_equality.py"
@echo "🔒 Verifying uv.lock is up-to-date..."
uv lock --check
UV_FROZEN=false uv lock --check
@echo "🔗 Checking SDK pin against workspace SDK (advisory)..."
@uv run --all-groups python "$(REPO_ROOT)/.github/scripts/check_sdk_pin.py" || \
{ rc=$$?; [ $$rc -eq 1 ] && echo "⚠️ Stale SDK pin (advisory, non-fatal)." || exit $$rc; }
+82 -2
View File
@@ -18,6 +18,7 @@ import tempfile
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import quote
from deepagents_code.config import _INHERITED_PYTHONPATH_ENV
@@ -136,7 +137,7 @@ def _extract_startup_error_marker(output: str) -> str | None:
def generate_langgraph_json(
output_dir: str | Path,
*,
graph_ref: str = "./server_graph.py:graph",
graph_ref: str = "./server_graph.py:make_graph",
env_file: str | None = None,
checkpointer_path: str | None = None,
) -> Path:
@@ -144,7 +145,8 @@ def generate_langgraph_json(
Args:
output_dir: Directory to write the config file.
graph_ref: Python module:variable reference to the graph.
graph_ref: Python "module:attribute" reference to the graph, where the
attribute is a graph factory (e.g. `make_graph`) or a graph object.
env_file: Optional path to an env file.
checkpointer_path: Import path to an async context manager that yields a
`BaseCheckpointSaver`. When set, the server persists checkpoint data
@@ -517,6 +519,84 @@ class ServerProcess:
self.stop()
raise
async def wait_for_graph_ready(
self,
graph_name: str = "agent",
*,
timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
) -> None:
"""Resolve the served graph once so lazy startup failures surface early.
Args:
graph_name: Registered graph name from `langgraph.json`.
timeout: Max seconds to wait for the graph readiness request.
Raises:
RuntimeError: If the server process exits or the graph endpoint
does not return a successful response.
"""
import httpx
if self._process is None:
msg = "Server process is not running"
raise RuntimeError(msg)
graph_url = f"{self.url}/assistants/{quote(graph_name, safe='')}/graph"
deadline = time.monotonic() + timeout
async with httpx.AsyncClient() as client:
while time.monotonic() < deadline:
if self._process.poll() is not None:
msg = f"Server process exited with code {self._process.returncode}"
output = self._read_log_file()
if output:
summary = _extract_startup_error_marker(output)
if summary:
msg += f": {summary}"
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
raise RuntimeError(msg)
remaining = max(0.1, deadline - time.monotonic())
try:
resp = await client.get(graph_url, timeout=remaining)
except (httpx.TransportError, httpx.TimeoutException, OSError) as exc:
output = self._read_log_file()
summary = _extract_startup_error_marker(output)
if self._process.poll() is not None:
msg = (
f"Server process exited with code "
f"{self._process.returncode}"
)
else:
msg = (
f"Server graph '{graph_name}' did not initialize within "
f"{timeout}s"
)
if summary:
msg += f": {summary}"
if output:
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
raise RuntimeError(msg) from exc
if resp.status_code == 200: # noqa: PLR2004
logger.info("Server graph %s is ready at %s", graph_name, self.url)
return
output = self._read_log_file()
msg = (
f"Server graph '{graph_name}' failed readiness check "
f"(status: {resp.status_code})"
)
summary = _extract_startup_error_marker(output)
if summary:
msg += f": {summary}"
if output:
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
raise RuntimeError(msg)
msg = f"Server graph '{graph_name}' did not initialize within {timeout}s"
raise RuntimeError(msg)
def _stop_process(self) -> None:
"""Stop only the server subprocess and its log file.
+79 -34
View File
@@ -1,21 +1,21 @@
"""Server-side graph entry point for `langgraph dev`.
This module is referenced by the generated `langgraph.json` and exposes the
agent graph as a module-level variable that the LangGraph server can load
and serve.
This module is referenced by the generated `langgraph.json` and exposes a graph
factory that the LangGraph server can load and serve.
The graph is created at module import time via `make_graph()`, which reads
configuration from `ServerConfig.from_env()` — the same dataclass the CLI uses
to *write* the configuration via `ServerConfig.to_env()`. This shared schema
ensures the two sides stay in sync.
The graph is created by `make_graph()`, which reads configuration from
`ServerConfig.from_env()` — the same dataclass the CLI uses to *write* the
configuration via `ServerConfig.to_env()`. This shared schema ensures the two
sides stay in sync.
"""
from __future__ import annotations
import asyncio
import atexit
import logging
import sys
from typing import Any
from typing import TYPE_CHECKING, Any
from deepagents_code._server_config import ServerConfig
from deepagents_code._startup_error import (
@@ -24,6 +24,9 @@ from deepagents_code._startup_error import (
)
from deepagents_code.project_utils import ProjectContext, get_server_project_context
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
logger = logging.getLogger(__name__)
_sandbox_cm: Any = None
@@ -63,7 +66,7 @@ def _get_mcp_session_manager() -> Any: # noqa: ANN401
return _mcp_session_manager
def _build_tools(
async def _build_tools(
config: ServerConfig,
project_context: ProjectContext | None,
) -> tuple[list[Any], list[Any] | None]:
@@ -72,11 +75,12 @@ def _build_tools(
Loads built-in tools (conditionally including web search when Tavily is
available) and MCP tools when enabled.
MCP discovery runs synchronously via `asyncio.run` because this function is
called during module-level graph construction (before the server's async
event loop is available). `stateless=True` ensures discovery only uses
throwaway sessions, while the shared runtime session manager binds real
sessions lazily inside the server loop on first tool invocation.
MCP discovery is awaited on the server's event loop: LangGraph invokes this
async factory on its running loop, so discovery must use `await` rather than
`asyncio.run` (which raises inside a running loop). `stateless=True` ensures
discovery only uses throwaway sessions, while the shared runtime session
manager binds real sessions lazily inside the server loop on first tool
invocation.
Args:
config: Deserialized server configuration.
@@ -98,20 +102,16 @@ def _build_tools(
mcp_server_info: list[Any] | None = None
if not config.no_mcp:
import asyncio
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
try:
mcp_tools, _, mcp_server_info = asyncio.run(
resolve_and_load_mcp_tools(
explicit_config_path=config.mcp_config_path,
no_mcp=config.no_mcp,
trust_project_mcp=config.trust_project_mcp,
project_context=project_context,
stateless=True,
session_manager=_get_mcp_session_manager(),
)
mcp_tools, _, mcp_server_info = await resolve_and_load_mcp_tools(
explicit_config_path=config.mcp_config_path,
no_mcp=config.no_mcp,
trust_project_mcp=config.trust_project_mcp,
project_context=project_context,
stateless=True,
session_manager=_get_mcp_session_manager(),
)
except FileNotFoundError:
logger.exception("MCP config file not found: %s", config.mcp_config_path)
@@ -129,7 +129,7 @@ def _build_tools(
return tools, mcp_server_info
def make_graph() -> Any: # noqa: ANN401
async def _make_graph() -> Any: # noqa: ANN401
"""Create the agent graph from environment-based configuration.
Reads `DEEPAGENTS_CODE_SERVER_*` env vars via `ServerConfig.from_env()`
@@ -151,11 +151,14 @@ def make_graph() -> Any: # noqa: ANN401
result = create_model(config.model, extra_kwargs=config.model_params)
result.apply_to_settings()
tools, mcp_server_info = _build_tools(config, project_context)
tools, mcp_server_info = await _build_tools(config, project_context)
# Create sandbox backend if a sandbox provider is configured.
# The context manager is held open at module level and cleaned up via
# atexit so the sandbox lives for the entire server process lifetime.
# The context manager is created here in the factory, but its reference is
# stored in a module-level global (and cleaned up via atexit) so the sandbox
# lives for the entire server process lifetime. `make_graph` caches the built
# graph, so this runs once per process despite LangGraph's per-run factory
# invocation.
global _sandbox_cm, _sandbox_backend # noqa: PLW0603
sandbox_backend = None
if config.sandbox_type:
@@ -236,8 +239,50 @@ def make_graph() -> Any: # noqa: ANN401
return agent
try:
graph = make_graph()
except Exception as exc: # noqa: BLE001 # top-level barrier: any failure must surface to parent
emit_startup_failure(exc)
sys.exit(1)
def _build_graph_factory() -> Callable[[], Awaitable[Any]]:
"""Build the cached async graph factory exposed to `langgraph dev`.
The returned coroutine function is what `langgraph.json` references. It keeps
its cache and lock in this closure rather than in module-level globals, so
importing the module (e.g. for import-only checks) introduces no shared
mutable state.
Returns:
A zero-arg async factory that builds the graph once and returns the
cached instance on every subsequent call.
"""
missing = object()
graph: Any = missing
lock = asyncio.Lock()
async def make_graph() -> Any: # noqa: ANN401
"""Create (or return the cached) agent graph for `langgraph dev`.
LangGraph loads this async factory from the generated `langgraph.json`
and invokes it lazily on its event loop — and again on every run. The
built graph is cached for the process lifetime so MCP discovery, sandbox
creation, and `atexit` registration each happen exactly once; re-running
them per request would re-discover MCP servers, leak sandbox sessions,
and stack duplicate `atexit` handlers. Any construction failure is
converted into a startup-error marker (scraped by the parent app
process) before exiting.
Returns:
Compiled LangGraph agent graph.
"""
nonlocal graph
if graph is not missing:
return graph
async with lock:
if graph is missing:
try:
graph = await _make_graph()
except Exception as exc: # noqa: BLE001 # top-level barrier: any construction failure must surface to the parent as a marker
emit_startup_failure(exc)
sys.exit(1)
return graph
return make_graph
make_graph = _build_graph_factory()
+2 -1
View File
@@ -110,7 +110,7 @@ def _scaffold_workspace(work_dir: Path) -> None:
# here breaks Windows because importlib treats backslash paths as module names.
generate_langgraph_json(
work_dir,
graph_ref="./server_graph.py:graph",
graph_ref="./server_graph.py:make_graph",
checkpointer_path="./checkpointer.py:create_checkpointer",
)
@@ -374,6 +374,7 @@ async def start_server_and_get_agent(
)
try:
await server.start()
await server.wait_for_graph_ready("agent")
except Exception:
server.stop()
raise
+23 -11
View File
@@ -8,25 +8,37 @@ If loading a file fails, the script prints the problematic filename and the deta
error traceback.
"""
import os
import random
import string
import sys
import tempfile
import traceback
from importlib.machinery import SourceFileLoader
if __name__ == "__main__":
files = sys.argv[1:]
has_failure = False
for file in files:
try:
module_name = "".join(
random.choice(string.ascii_letters) for _ in range(20)
)
SourceFileLoader(module_name, file).load_module()
except Exception:
has_failure = True
print(file)
traceback.print_exc()
print()
with tempfile.TemporaryDirectory() as home:
# Point the home directory at a throwaway dir so importing a module can't
# read or depend on the developer's real `~` state (e.g. `~/.deepagents`
# config, MCP auth tokens). `Path.home()` resolves from `HOME` on POSIX
# and `USERPROFILE` / `HOMEDRIVE`+`HOMEPATH` on Windows, so override all
# of them to keep the isolation cross-platform.
os.environ["HOME"] = home
os.environ["USERPROFILE"] = home
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
for file in files:
try:
module_name = "".join(
random.choice(string.ascii_letters) for _ in range(20)
)
SourceFileLoader(module_name, file).load_module()
except Exception:
has_failure = True
print(file)
traceback.print_exc()
print()
sys.exit(1 if has_failure else 0)
+91
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import socket
import threading
from types import SimpleNamespace
from typing import TYPE_CHECKING, Self
from unittest.mock import AsyncMock, MagicMock, patch
@@ -51,6 +52,31 @@ class _FakeSocket:
return self._sockname
class _FakeAsyncClient:
"""Minimal async `httpx.AsyncClient` stand-in for readiness tests."""
def __init__(self, response: object) -> None:
self.response = response
self.urls: list[str] = []
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def get(
self,
url: str,
*,
timeout: float, # noqa: ARG002, ASYNC109 # mirrors httpx.AsyncClient.get
) -> object:
self.urls.append(url)
if isinstance(self.response, BaseException):
raise self.response
return self.response
class TestPortInUse:
def test_free_port(self) -> None:
fake_socket = _FakeSocket()
@@ -204,6 +230,71 @@ class TestWaitForServerHealthy:
class TestServerProcess:
async def test_wait_for_graph_ready_resolves_graph_endpoint(self) -> None:
"""Graph readiness should force LangGraph to resolve graph factories."""
client = _FakeAsyncClient(SimpleNamespace(status_code=200))
process = MagicMock()
process.poll.return_value = None
server = ServerProcess(host="127.0.0.1", port=2024)
server._process = process
with patch("httpx.AsyncClient", return_value=client):
await server.wait_for_graph_ready("agent")
assert client.urls == ["http://127.0.0.1:2024/assistants/agent/graph"]
async def test_wait_for_graph_ready_surfaces_startup_marker(
self, tmp_path: Path
) -> None:
"""Readiness failures should preserve marked subprocess startup errors."""
log_path = tmp_path / "server.log"
log_path.write_text(
"booting\n"
"DEEPAGENTS_STARTUP_ERROR:Sandbox creation failed for 'modal': boom\n"
)
log_file = MagicMock()
log_file.name = str(log_path)
client = _FakeAsyncClient(SimpleNamespace(status_code=500))
process = MagicMock()
process.poll.return_value = None
server = ServerProcess(host="127.0.0.1", port=2024)
server._process = process
server._log_file = log_file
with (
patch("httpx.AsyncClient", return_value=client),
pytest.raises(RuntimeError, match="Sandbox creation failed"),
):
await server.wait_for_graph_ready("agent")
async def test_wait_for_graph_ready_checks_logs_after_transport_error(
self, tmp_path: Path
) -> None:
"""Dropped graph requests should still surface startup markers."""
log_path = tmp_path / "server.log"
log_path.write_text(
"booting\nDEEPAGENTS_STARTUP_ERROR:ModelConfigError: missing API key\n"
)
log_file = MagicMock()
log_file.name = str(log_path)
client = _FakeAsyncClient(OSError("connection closed"))
process = MagicMock()
process.poll.return_value = 1
process.returncode = 3
server = ServerProcess(host="127.0.0.1", port=2024)
server._process = process
server._log_file = log_file
with (
patch("httpx.AsyncClient", return_value=client),
pytest.raises(RuntimeError, match="ModelConfigError: missing API key"),
):
await server.wait_for_graph_ready("agent")
async def test_start_cleans_up_partial_state_on_health_failure(
self, tmp_path: Path
) -> None:
@@ -6,15 +6,13 @@ import importlib
import os
import sys
from types import ModuleType, SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from deepagents_code._env_vars import SERVER_ENV_PREFIX
from deepagents_code._server_config import ServerConfig
if TYPE_CHECKING:
import pytest
def _import_fresh_server_graph() -> ModuleType:
"""Import `deepagents_code.server_graph` from a clean module state."""
@@ -33,8 +31,43 @@ def _module_with_attrs(name: str, **attrs: object) -> ModuleType:
class TestServerGraph:
"""Tests for server-mode graph bootstrap."""
def test_auto_discovery_loads_mcp_without_explicit_config(self) -> None:
"""Server mode should auto-discover MCP configs when no path is passed."""
async def test_make_graph_caches_first_constructed_graph(self) -> None:
"""Repeated factory access should preserve process-lifetime resources."""
graph_obj = object()
module = _import_fresh_server_graph()
with patch.object(
module, "_make_graph", new=AsyncMock(return_value=graph_obj)
) as make_graph:
assert await module.make_graph() is graph_obj
assert await module.make_graph() is graph_obj
make_graph.assert_awaited_once_with()
async def test_make_graph_emits_marker_and_exits_on_failure(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""A construction failure must emit the startup marker, then exit non-zero."""
from deepagents_code._startup_error import STARTUP_ERROR_MARKER
module = _import_fresh_server_graph()
with (
patch.object(
module,
"_make_graph",
new=AsyncMock(side_effect=ValueError("boom: bad model")),
),
pytest.raises(SystemExit) as exc_info,
):
await module.make_graph()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert f"{STARTUP_ERROR_MARKER}ValueError: boom: bad model" in captured.err
async def test_auto_discovery_loads_mcp_without_explicit_config(self) -> None:
"""Server mode should auto-discover MCP configs when the graph is built."""
graph_obj = object()
model_obj = object()
fetch_tool = object()
@@ -80,8 +113,6 @@ class TestServerGraph:
resolve_and_load_mcp_tools=resolve_mcp_tools,
)
# Build env from ServerConfig to exercise the same serialization
# path the real CLI uses.
config = ServerConfig(no_mcp=False)
env_overrides = {}
for suffix, value in config.to_env().items():
@@ -113,6 +144,8 @@ class TestServerGraph:
os.environ.pop(f"{SERVER_ENV_PREFIX}{suffix}", None)
module = _import_fresh_server_graph()
resolve_mcp_tools.assert_not_awaited()
assert await module.make_graph() is graph_obj
resolve_mcp_tools.assert_awaited_once()
kwargs = resolve_mcp_tools.await_args_list[0].kwargs
@@ -143,7 +176,6 @@ class TestServerGraph:
project_context=None,
async_subagents=None,
)
assert module.graph is graph_obj
class TestStartupErrorMarker:
@@ -149,6 +149,7 @@ class TestStartServerAndGetAgent:
mock_server = MagicMock()
mock_server.start = AsyncMock()
mock_server.wait_for_graph_ready = AsyncMock()
mock_server.url = "http://127.0.0.1:2024"
mock_agent = object()
@@ -175,9 +176,10 @@ class TestStartServerAndGetAgent:
assert agent is mock_agent
assert server is mock_server
assert manager is None
mock_server.wait_for_graph_ready.assert_awaited_once_with("agent")
kwargs = mock_generate_langgraph_json.call_args.kwargs
assert kwargs["graph_ref"] == "./server_graph.py:graph"
assert kwargs["graph_ref"] == "./server_graph.py:make_graph"
assert kwargs["checkpointer_path"] == "./checkpointer.py:create_checkpointer"
async def test_passes_scaffold_hook_to_server_process(
@@ -193,6 +195,7 @@ class TestStartServerAndGetAgent:
mock_server = MagicMock()
mock_server.start = AsyncMock()
mock_server.wait_for_graph_ready = AsyncMock()
mock_server.url = "http://127.0.0.1:2024"
with (
@@ -216,6 +219,48 @@ class TestStartServerAndGetAgent:
assert mock_server_process.call_args.kwargs["scaffold"] is mock_scaffold
async def test_stops_server_when_graph_readiness_fails(
self, tmp_path: Path, monkeypatch
) -> None:
"""Lazy graph initialization failures should fail startup before return."""
project_root = tmp_path / "project"
project_root.mkdir()
monkeypatch.chdir(project_root)
work_dir = tmp_path / "runtime"
work_dir.mkdir()
mock_server = MagicMock()
mock_server.start = AsyncMock()
mock_server.wait_for_graph_ready = AsyncMock(
side_effect=RuntimeError("graph failed")
)
mock_server.stop = MagicMock()
mock_server.url = "http://127.0.0.1:2024"
with (
patch.dict(os.environ, {}, clear=False),
patch(
"deepagents_code.server_manager.tempfile.mkdtemp",
return_value=str(work_dir),
),
patch("deepagents_code.server_manager.shutil.copy2"),
patch("deepagents_code.server_manager._write_checkpointer"),
patch("deepagents_code.server_manager._write_pyproject"),
patch("deepagents_code.server.ServerProcess", return_value=mock_server),
patch("deepagents_code.remote_client.RemoteAgent") as mock_agent,
pytest.raises(RuntimeError, match="graph failed"),
):
await start_server_and_get_agent(
assistant_id="agent",
mcp_config_path=None,
)
mock_server.start.assert_awaited_once()
mock_server.wait_for_graph_ready.assert_awaited_once_with("agent")
mock_server.stop.assert_called_once()
mock_agent.assert_not_called()
def test_relative_paths_written_verbatim_to_langgraph_json(
self, tmp_path: Path
) -> None:
@@ -226,11 +271,11 @@ class TestStartServerAndGetAgent:
generate_langgraph_json(
tmp_path,
graph_ref="./server_graph.py:graph",
graph_ref="./server_graph.py:make_graph",
checkpointer_path="./checkpointer.py:create_checkpointer",
)
config = json.loads((tmp_path / "langgraph.json").read_text())
assert config["graphs"]["agent"] == "./server_graph.py:graph"
assert config["graphs"]["agent"] == "./server_graph.py:make_graph"
assert config["checkpointer"]["path"] == "./checkpointer.py:create_checkpointer"