feat(cli): surface sandbox startup errors in server crash messages (#3306)

When a sandbox provider is missing or misconfigured, the server
subprocess exits before the health endpoint comes up. The parent CLI
process raises a `RuntimeError` that includes the last few thousand
characters of log output, but the actual error message is often buried
under a Python traceback and Uvicorn shutdown noise. This makes it hard
for users to understand why `deepagents up` failed.
This commit is contained in:
Mason Daugherty
2026-05-10 23:43:01 -04:00
committed by GitHub
parent 779f7f45c5
commit 2606d57b62
3 changed files with 74 additions and 9 deletions
+21
View File
@@ -34,6 +34,8 @@ _LOG_TAIL_CHARS = 3000
"""Max chars of subprocess log appended to the early-exit `RuntimeError`
message. Enough to carry a Python traceback without flooding the TUI banner
when it surfaces via `ServerStartFailed`."""
_STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
"""Machine-readable prefix emitted by the server subprocess for known startup errors."""
def _port_in_use(host: str, port: int) -> bool:
@@ -86,6 +88,22 @@ def get_server_url(host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> str:
return f"http://{host}:{port}"
def _extract_startup_error_marker(output: str) -> str | None:
"""Extract a marked startup error from subprocess output.
Args:
output: Combined stdout/stderr captured from the server subprocess.
Returns:
The marked startup error message, or `None` if no marker was emitted.
"""
for line in reversed(output.splitlines()):
if _STARTUP_ERROR_MARKER in line:
_, summary = line.rsplit(_STARTUP_ERROR_MARKER, 1)
return summary.strip() or None
return None
def generate_langgraph_json(
output_dir: str | Path,
*,
@@ -204,6 +222,9 @@ async def wait_for_server_healthy(
output = read_log() if read_log else ""
msg = f"Server process exited with code {process.returncode}"
if output:
summary = _extract_startup_error_marker(output)
if summary:
msg += f": {summary}"
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
raise RuntimeError(msg)
+20 -9
View File
@@ -26,6 +26,20 @@ logger = logging.getLogger(__name__)
_sandbox_cm: Any = None
_sandbox_backend: Any = None
_mcp_session_manager: Any = None
_STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
def _print_startup_error(message: str) -> None:
"""Print a startup error for both humans and the parent CLI process.
Args:
message: Concise startup failure to surface in the parent process.
"""
print(message, file=sys.stderr) # noqa: T201 # stderr fallback for logs
print( # noqa: T201 # machine-readable marker consumed by server.py
f"{_STARTUP_ERROR_MARKER}{message}",
file=sys.stderr,
)
def _get_mcp_session_manager() -> Any: # noqa: ANN401
@@ -163,23 +177,20 @@ def make_graph() -> Any: # noqa: ANN401
logger.exception(
"Sandbox provider '%s' is not installed", config.sandbox_type
)
print( # noqa: T201 # stderr fallback — logger may not reach parent process
f"Sandbox provider '{config.sandbox_type}' is not installed",
file=sys.stderr,
_print_startup_error(
f"Sandbox provider '{config.sandbox_type}' is not installed"
)
sys.exit(1)
except NotImplementedError:
logger.exception("Sandbox type '%s' is not supported", config.sandbox_type)
print( # noqa: T201 # stderr fallback — logger may not reach parent process
f"Sandbox type '{config.sandbox_type}' is not supported",
file=sys.stderr,
_print_startup_error(
f"Sandbox type '{config.sandbox_type}' is not supported"
)
sys.exit(1)
except Exception as exc:
logger.exception("Sandbox creation failed for '%s'", config.sandbox_type)
print( # noqa: T201 # stderr fallback — logger may not reach parent process
f"Sandbox creation failed for '{config.sandbox_type}': {exc}",
file=sys.stderr,
_print_startup_error(
f"Sandbox creation failed for '{config.sandbox_type}': {exc}"
)
sys.exit(1)
+33
View File
@@ -134,6 +134,39 @@ class TestWaitForServerHealthy:
read_log=lambda: "some log output",
)
async def test_early_exit_promotes_marked_startup_error(self) -> None:
"""Marked server startup errors should survive app error trimming."""
process = MagicMock()
process.poll.return_value = 1
process.returncode = 3
log = (
"Traceback (most recent call last):\n"
"ValueError: No Runloop API key found\n"
"Sandbox creation failed for 'runloop': No Runloop API key found. "
"Set RUNLOOP_API_KEY or DEEPAGENTS_CLI_RUNLOOP_API_KEY.\n"
"DEEPAGENTS_STARTUP_ERROR:Sandbox creation failed for 'runloop': "
"No Runloop API key found. Set RUNLOOP_API_KEY or "
"DEEPAGENTS_CLI_RUNLOOP_API_KEY.\n"
"2026-05-11T03:37:44.911664Z [error ] "
"Application startup failed. Exiting. [uvicorn.error]"
)
with pytest.raises(RuntimeError) as exc_info:
await wait_for_server_healthy(
"http://localhost:2024",
timeout=5,
process=process,
read_log=lambda: log,
)
first_line = str(exc_info.value).splitlines()[0]
assert first_line == (
"Server process exited with code 3: Sandbox creation failed for "
"'runloop': No Runloop API key found. Set RUNLOOP_API_KEY or "
"DEEPAGENTS_CLI_RUNLOOP_API_KEY."
)
async def test_raises_on_timeout(self) -> None:
"""Timeout exhaustion raises RuntimeError."""
import httpx