92d95dee68
* feat(memory): add observation memory lifecycle Add file-backed observation memory with deterministic markdown records, structured record_observation tooling, startup indexing, and profile/observation prompt guidance. Launch post-turn and post-subagent EvoMemory workers through LangGraph dev so completed runs can update profile memory, save durable observations, and write subagent execution summaries without blocking the active agent. Wire memory middleware into the main agent, subagents, async graphs, TUI status reporting, worker activity accounting, and observation-aware research prompts, with regression coverage for storage, lifecycle scheduling, graph registration, status display, and stream reset behavior. * fix(cli): sync background agent server on resume Resume flows now need to keep the LangGraph dev background server aligned with the active workspace even when async subagents are disabled. EvoMemory workers use that server too, so gating resume-time sync on enable_async_subagents could leave workers pinned to the launch workspace after resuming a thread from another workspace. Run workspace sync unconditionally for Rich CLI and Textual resume paths, while preserving WorkspaceMismatchError handling so failed sync aborts the resume before mutating the active thread or workspace. Propagate aborted resume callbacks through the command UI so channel-issued /resume commands do not send false success or history output. Channel slash dispatch now treats CommandManager-caught command errors as command errors and skips completion hooks for those failed commands. Add regression coverage for disabled async subagents, callback aborts, and channel command error reporting. * fix(cli): prepare serve resume workspace before adopting Load the resumed workspace agent and sync the background server as a single pre-adoption step. Restore the previous active workspace if preparation fails so serve mode keeps using the old session consistently. * fix(memory): untrack abandoned worker status watches Stop treating watcher shutdown as confirmed worker completion. Terminal worker statuses still count memory deltas, while poll failures or watcher setup failures now remove the active run without crediting partial outputs. * fix(cli): report channel command failures accurately Treat command_error as a None sentinel so empty error strings still fail, and let TUI resumes continue only on non-mismatch background-server sync failures while reporting degraded mode. * fix(stream): clear memory counters for resume streams Reset completed-memory counters for every new agent stream, including Command-based HITL and resume streams, so saved-memory indicators do not leak across turns. * docs(tools): make observation recording guidance conditional Clarify that agents should call record_observation only when the observation tool is available, preserving the existing durability and usefulness criteria. * feat(config): add controls for profile and observation memory Add config flags for profile memory, observation memory, observation writer placement, and background memory workers. Wire the controls through main agents, subagents, EvoMemory middleware, and memory lifecycle workers so observation writes can be assigned to the live agent, subagent worker, both, or neither. Keep turn memory workers profile-only and make prompts reflect the available observation read/write paths. Skip langgraph dev startup when neither async subagents nor memory workers need the background server. Add coverage for config parsing, prompt gating, middleware wiring, and worker tool availability. * test(cli): include memory defaults in serve config stubs * fix(memory): offload async worker launch blocking calls Run the langgraph-dev health check and memory-output snapshot in worker threads from the async EvoMemory launcher so it does not block the event loop. * chore(memory): harden turn worker subagent guardrail * chore(memory): refresh profile context per request * fix(memory): offload async profile file reads * fix(memory): offload async worker completion accounting
269 lines
11 KiB
Python
269 lines
11 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.config.settings import EvoScientistConfig
|
|
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_starts_when_async_disabled_but_memory_workers_enabled(self, tmp_path):
|
|
"""EvoMemory workers can require langgraph dev even without async subagents."""
|
|
cfg = EvoScientistConfig()
|
|
cfg.enable_async_subagents = False
|
|
cfg.memory_workers_enabled = True
|
|
cfg.langgraph_dev_port = 6174
|
|
cfg.langgraph_dev_file_persistence = True
|
|
proc = MagicMock()
|
|
with (
|
|
patch.object(manager, "is_langgraph_dev_running", return_value=False),
|
|
patch.object(manager, "start_langgraph_dev", return_value=proc) as start,
|
|
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
|
|
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
|
|
):
|
|
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
|
|
|
|
assert result is proc
|
|
start.assert_called_once()
|
|
assert manager.is_async_subagents_available() is True
|
|
|
|
def test_skips_when_async_and_memory_workers_disabled(self, tmp_path):
|
|
"""No background server is needed without async subagents or workers."""
|
|
cfg = EvoScientistConfig()
|
|
cfg.enable_async_subagents = False
|
|
cfg.memory_workers_enabled = False
|
|
cfg.langgraph_dev_port = 6174
|
|
cfg.langgraph_dev_file_persistence = True
|
|
with (
|
|
patch.object(manager, "is_langgraph_dev_running") as mock_running,
|
|
patch.object(manager, "start_langgraph_dev") as start,
|
|
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
|
|
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
|
|
):
|
|
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
|
|
|
|
assert result is None
|
|
mock_running.assert_not_called()
|
|
start.assert_not_called()
|
|
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 = EvoScientistConfig()
|
|
cfg.enable_async_subagents = True
|
|
cfg.langgraph_dev_port = 6174
|
|
cfg.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
|