f41584e10b
* Refactor sub-agent architecture and introduce async support - Removed the legacy subagent.yaml file and replaced it with individual YAML files for each sub-agent in the subagents directory. - Updated the load_subagents function to support both directory and single file layouts for loading sub-agent configurations. - Added new langgraph_dev module for managing async sub-agent lifecycle and deployment. - Created graphs for async sub-agents (writing-agent, data-analysis-agent) and updated langgraph.json for deployment. - Introduced new sub-agent definitions for planner, research, debug, code, and writing agents with appropriate system prompts and configurations. - Enhanced package data inclusion in pyproject.toml to accommodate new sub-agent YAML files. * Refactor code for improved readability by consolidating conditional statements and formatting * feat: enhance async sub-agent support with workspace synchronization and user feedback - Added console status messages during async sub-agent server startup and workspace synchronization to improve user experience. - Implemented a new WorkspaceSyncWidget for live feedback during workspace sync operations. - Updated onboarding to reject occupied ports and ensure proper workspace handling for async sub-agents. - Introduced locking mechanisms to manage concurrent access to langgraph dev processes and workspace states. * feat: add async sub-agent configuration and server management functions * feat: improve port occupation handling and log file management in start_langgraph_dev * feat: enhance async sub-agent handling and introduce comprehensive tests - Updated `_maybe_swap_async_subagents` to improve async sub-agent management, ensuring internal flags are stripped before handoff. - Enhanced port management in `onboard.py` to allow reuse of occupied ports if already running by the same service. - Introduced file locking in `manager.py` to prevent race conditions during concurrent CLI invocations. - Added new tests for async sub-agent swapping and langgraph manager functionalities to ensure reliability and correctness. - Updated dependencies in `pyproject.toml` to include `psutil` and `filelock`. * fix(docs): clarify sub-agent configuration in README * test(manager): isolate _PID_DIR + tighten reuse-path assertion Addresses CodeRabbit review on tests/test_langgraph_manager.py: - Patch _PID_DIR to tmp_path so the FileLock setup in ensure_langgraph_dev doesn't mkdir the user's real ~/.config/evoscientist/ dir as a test side-effect. - Tighten "result is None or hasattr(result, 'poll')" to a strict "result is None" — the reuse path returns None unconditionally, so the OR clause was hiding potential regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(manager): clean up stale PID file when unrelated process reuses PID * feat(tests): add validation tests for async flag in load_subagents * fix(load_subagents): restrict to .yaml files and clarify configuration handling * fix(load_subagents): improve error handling for non-dict specifications in YAML * feat(onboard): add "LangGraph Port" step to onboarding process * feat(langgraph): add concurrency configuration for langgraph dev workers * feat(async-subagents): enhance MCP tool routing for async sub-agents * fix(manager): update exception handling for connection errors and prevent zombie processes --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
9.5 KiB
Python
235 lines
9.5 KiB
Python
"""Happy-path tests for langgraph_dev.manager.
|
|
|
|
Mocks httpx, psutil, subprocess.Popen, and module-level state so the tests
|
|
run on CI without requiring the langgraph CLI to be installed or any port
|
|
to be available.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from EvoScientist.langgraph_dev import manager
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_module_state():
|
|
"""Reset manager module globals before each test for isolation."""
|
|
manager._PROCESS = None
|
|
manager._PROCESS_WORKSPACE = None
|
|
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
|
yield
|
|
manager._PROCESS = None
|
|
manager._PROCESS_WORKSPACE = None
|
|
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
|
|
|
|
|
# =============================================================================
|
|
# is_langgraph_dev_running
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsLanggraphDevRunning:
|
|
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
|
|
def test_returns_false_on_connect_error(self, mock_get):
|
|
mock_get.side_effect = httpx.ConnectError("refused")
|
|
assert manager.is_langgraph_dev_running(port=6174) is False
|
|
|
|
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
|
|
def test_returns_false_on_timeout(self, mock_get):
|
|
mock_get.side_effect = httpx.TimeoutException("slow")
|
|
assert manager.is_langgraph_dev_running(port=6174) is False
|
|
|
|
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
|
|
def test_returns_true_on_200(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=200)
|
|
assert manager.is_langgraph_dev_running(port=6174) is True
|
|
# Verify it probed /ok at the configured port.
|
|
called_url = mock_get.call_args[0][0]
|
|
assert called_url == "http://localhost:6174/ok"
|
|
|
|
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
|
|
def test_returns_false_on_non_200(self, mock_get):
|
|
mock_get.return_value = MagicMock(status_code=503)
|
|
assert manager.is_langgraph_dev_running(port=6174) is False
|
|
|
|
|
|
# =============================================================================
|
|
# _list_pids_on_port
|
|
# =============================================================================
|
|
|
|
|
|
class TestListPidsOnPort:
|
|
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
|
|
def test_empty_when_no_connections(self, mock_net):
|
|
mock_net.return_value = []
|
|
assert manager._list_pids_on_port(6174) == []
|
|
|
|
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
|
|
def test_returns_pid_for_matching_port(self, mock_net):
|
|
mock_net.return_value = [
|
|
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=12345),
|
|
SimpleNamespace(laddr=SimpleNamespace(port=8080), pid=99999),
|
|
]
|
|
result = manager._list_pids_on_port(6174)
|
|
assert result == [12345]
|
|
|
|
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
|
|
def test_filters_none_pid(self, mock_net):
|
|
mock_net.return_value = [
|
|
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=None),
|
|
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=12345),
|
|
]
|
|
result = manager._list_pids_on_port(6174)
|
|
assert result == [12345]
|
|
|
|
def test_returns_empty_on_access_denied(self):
|
|
with patch.object(
|
|
manager.psutil,
|
|
"net_connections",
|
|
side_effect=manager.psutil.AccessDenied(),
|
|
):
|
|
assert manager._list_pids_on_port(6174) == []
|
|
|
|
|
|
# =============================================================================
|
|
# _kill_owned_stale_process
|
|
# =============================================================================
|
|
|
|
|
|
class TestKillOwnedStaleProcess:
|
|
def test_returns_false_if_no_pid_file(self, tmp_path):
|
|
with patch.object(manager, "_PID_FILE", tmp_path / "missing.pid"):
|
|
assert manager._kill_owned_stale_process(6174) is False
|
|
|
|
def test_returns_false_if_pid_file_unreadable(self, tmp_path):
|
|
pid_file = tmp_path / "bad.pid"
|
|
pid_file.write_text("not-a-number")
|
|
with patch.object(manager, "_PID_FILE", pid_file):
|
|
assert manager._kill_owned_stale_process(6174) is False
|
|
|
|
def test_returns_false_if_pid_not_in_occupiers(self, tmp_path):
|
|
pid_file = tmp_path / "lg.pid"
|
|
pid_file.write_text("12345")
|
|
with (
|
|
patch.object(manager, "_PID_FILE", pid_file),
|
|
patch.object(manager, "_list_pids_on_port", return_value=[99999]),
|
|
):
|
|
assert manager._kill_owned_stale_process(6174) is False
|
|
# PID file should be left intact — the port is held by someone
|
|
# else, not a stale ours.
|
|
assert pid_file.exists()
|
|
|
|
def test_refuses_to_kill_recycled_pid(self, tmp_path):
|
|
"""PID matches but cmdline doesn't contain 'langgraph' → don't kill."""
|
|
pid_file = tmp_path / "lg.pid"
|
|
pid_file.write_text("12345")
|
|
fake_proc = MagicMock()
|
|
fake_proc.cmdline.return_value = ["bash", "-c", "echo hi"]
|
|
with (
|
|
patch.object(manager, "_PID_FILE", pid_file),
|
|
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
|
|
patch.object(manager.psutil, "Process", return_value=fake_proc),
|
|
):
|
|
assert manager._kill_owned_stale_process(6174) is False
|
|
fake_proc.kill.assert_not_called()
|
|
# PID file should be removed — the entry is stale (our process is
|
|
# gone, PID was recycled by an unrelated process).
|
|
assert not pid_file.exists()
|
|
|
|
def test_kills_when_cmdline_matches_langgraph(self, tmp_path):
|
|
"""Owned PID + cmdline contains 'langgraph' → kill + cleanup PID file."""
|
|
pid_file = tmp_path / "lg.pid"
|
|
pid_file.write_text("12345")
|
|
fake_proc = MagicMock()
|
|
fake_proc.cmdline.return_value = [
|
|
"/usr/bin/python",
|
|
"/usr/bin/langgraph",
|
|
"dev",
|
|
]
|
|
with (
|
|
patch.object(manager, "_PID_FILE", pid_file),
|
|
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
|
|
patch.object(manager.psutil, "Process", return_value=fake_proc),
|
|
):
|
|
assert manager._kill_owned_stale_process(6174) is True
|
|
fake_proc.kill.assert_called_once()
|
|
assert not pid_file.exists()
|
|
|
|
def test_handles_dead_pid(self, tmp_path):
|
|
"""PID file claims a PID but the process is gone → cleanup PID file, no error."""
|
|
pid_file = tmp_path / "lg.pid"
|
|
pid_file.write_text("12345")
|
|
with (
|
|
patch.object(manager, "_PID_FILE", pid_file),
|
|
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
|
|
patch.object(
|
|
manager.psutil,
|
|
"Process",
|
|
side_effect=manager.psutil.NoSuchProcess(12345),
|
|
),
|
|
):
|
|
assert manager._kill_owned_stale_process(6174) is False
|
|
assert not pid_file.exists()
|
|
|
|
|
|
# =============================================================================
|
|
# ensure_langgraph_dev — high-level orchestration
|
|
# =============================================================================
|
|
|
|
|
|
class TestEnsureLanggraphDev:
|
|
def test_returns_none_when_async_disabled(self):
|
|
cfg = SimpleNamespace(enable_async_subagents=False)
|
|
assert manager.ensure_langgraph_dev(cfg) is None
|
|
# And the availability flag should remain False.
|
|
assert manager.is_async_subagents_available() is False
|
|
|
|
def test_reuses_existing_healthy_subprocess(self, tmp_path):
|
|
"""When the subprocess is already running, no new Popen call."""
|
|
cfg = SimpleNamespace(
|
|
enable_async_subagents=True,
|
|
langgraph_dev_port=6174,
|
|
langgraph_dev_file_persistence=True,
|
|
)
|
|
with (
|
|
patch.object(
|
|
manager, "is_langgraph_dev_running", return_value=True
|
|
) as mock_running,
|
|
patch.object(manager, "start_langgraph_dev") as mock_start,
|
|
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
|
|
# Isolate from real ``~/.config/evoscientist/`` — without this
|
|
# patch, the FileLock setup would mkdir the user's actual config
|
|
# dir as a test side-effect.
|
|
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
|
|
):
|
|
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
|
|
# We didn't spawn anything — there's already a healthy server.
|
|
mock_start.assert_not_called()
|
|
# Reuse path returns None (we don't own the existing process).
|
|
assert result is None
|
|
# is_async_subagents_available was flipped True.
|
|
assert manager.is_async_subagents_available() is True
|
|
# Health check was called at least once.
|
|
assert mock_running.called
|
|
|
|
|
|
# =============================================================================
|
|
# is_async_subagents_available — module state
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsAsyncSubagentsAvailable:
|
|
def test_starts_false(self):
|
|
assert manager.is_async_subagents_available() is False
|
|
|
|
def test_reflects_module_state(self):
|
|
manager._ASYNC_SUBAGENTS_AVAILABLE = True
|
|
assert manager.is_async_subagents_available() is True
|
|
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
|
assert manager.is_async_subagents_available() is False
|