perf: cut startup latency — lazy import surface, langgraph dev keepalive, indexed thread listing (#407)
* feat: add thread metadata index for improved performance in thread listing - Implemented a new SQLite index on the `checkpoints` table to optimize thread listing queries by indexing relevant metadata fields. - Updated the `list_threads` function to ensure the index is created if it does not exist. - Added a test to verify the creation of the metadata index during thread listing. feat: enhance workspace sidecar management with owner tracking - Modified the workspace sidecar to include `owner_pids` to track the current process owners. - Updated tests to validate the new owner tracking functionality and ensure proper behavior when managing workspace sidecars. chore: introduce model registry for streamlined model management - Created a new `registry.py` file to maintain a comprehensive model registry, including model names, IDs, providers, and routing tables. - Added functions to retrieve models by provider and list available models, enhancing the modularity and maintainability of model management. * feat: enhance workspace sidecar management and improve thread metadata indexing * fix(tests): ensure sidecar correctly registers owner with original workspace and pid * refactor: simplify workspace sidecar management by removing owner tracking * feat(server): add commands to manage background langgraph dev server - Introduced `server_app` for managing the langgraph dev server with commands to check status and stop the server. - Enhanced workspace sidecar management to include configuration fingerprint for drift detection. - Updated deployment functions to handle server configuration and state more effectively. * feat(server): enhance server status command to display PID with stale record warning * feat(langgraph_dev): exclusion-set config fingerprint, webui keepalive, unified stop guidance * fix(cli): platform-specific manual-stop hint; document keepalive endpoint-change limitation
This commit is contained in:
@@ -134,6 +134,7 @@ def _run_deploy_once(
|
||||
jobs_per_worker=10,
|
||||
deploy_mode=False,
|
||||
tunnel=False,
|
||||
config_fingerprint=None,
|
||||
):
|
||||
captured["langgraph_dev_started"] = True
|
||||
captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None
|
||||
|
||||
@@ -142,6 +142,36 @@ def test_ensure_langgraph_dev_refuses_on_workspace_mismatch(
|
||||
manager.ensure_langgraph_dev(cfg, workspace_dir=ws_b)
|
||||
assert str(ws_a.resolve()) in str(exc.value)
|
||||
assert str(ws_b) in str(exc.value)
|
||||
# Without keepalive a mismatch means a live session — no stop suggestion,
|
||||
# keeping the message identical to pre-keepalive behavior.
|
||||
assert "EvoSci server stop" not in str(exc.value)
|
||||
|
||||
|
||||
def test_mismatch_error_suggests_server_stop_under_keepalive(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
"""With keepalive on, the leftover may be ownerless — the error points at
|
||||
`EvoSci server stop`, which kills AND cleans the records (a raw kill
|
||||
would leave stale files behind)."""
|
||||
ws_a = tmp_path / "A"
|
||||
ws_b = tmp_path / "B"
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, workspace_sidecar=tmp_path / "ws.json"),
|
||||
)
|
||||
manager._write_workspace_sidecar(workspace_dir=ws_a, pid=99999)
|
||||
|
||||
monkeypatch.setattr(manager, "is_langgraph_dev_running", lambda **_kw: True)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
monkeypatch.setattr(manager, "_PROCESS_WORKSPACE", None)
|
||||
|
||||
cfg = manager.EvoScientistConfig()
|
||||
cfg.enable_async_subagents = True
|
||||
cfg.langgraph_dev_keepalive = True
|
||||
with pytest.raises(manager.WorkspaceMismatchError) as exc:
|
||||
manager.ensure_langgraph_dev(cfg, workspace_dir=ws_b)
|
||||
assert "EvoSci server stop" in str(exc.value)
|
||||
|
||||
|
||||
def test_ensure_langgraph_dev_refuses_on_mismatch_with_stale_process(
|
||||
@@ -236,3 +266,304 @@ def test_stop_langgraph_dev_removes_sidecar(tmp_path, monkeypatch, runtime_paths
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
manager.stop_langgraph_dev()
|
||||
assert not sidecar.exists()
|
||||
|
||||
|
||||
def test_keepalive_skips_atexit_registration(tmp_path, monkeypatch, runtime_paths):
|
||||
"""keepalive=True leaves the server running on exit; False registers stop."""
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, workspace_sidecar=tmp_path / "ws.json"),
|
||||
)
|
||||
monkeypatch.setattr(manager, "is_langgraph_dev_running", lambda **_kw: False)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
monkeypatch.setattr(manager, "_PROCESS_WORKSPACE", None)
|
||||
fake_proc = object()
|
||||
monkeypatch.setattr(manager, "start_langgraph_dev", lambda **kw: fake_proc)
|
||||
registered = []
|
||||
monkeypatch.setattr(
|
||||
manager.atexit, "register", lambda *a, **kw: registered.append(a)
|
||||
)
|
||||
|
||||
cfg = manager.EvoScientistConfig()
|
||||
assert cfg.langgraph_dev_keepalive is False, "keepalive must default to off"
|
||||
cfg.enable_async_subagents = True
|
||||
cfg.langgraph_dev_keepalive = True
|
||||
assert manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path / "A") is fake_proc
|
||||
assert registered == []
|
||||
|
||||
cfg.langgraph_dev_keepalive = False
|
||||
assert manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path / "A") is fake_proc
|
||||
assert registered
|
||||
assert registered[0][0] is manager.stop_langgraph_dev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config fingerprint + drift detection + explicit server stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _dead_pid() -> int:
|
||||
"""Spawn-and-reap a process so its pid is reliably dead."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
return proc.pid
|
||||
|
||||
|
||||
def test_sidecar_records_config_fingerprint_when_passed(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, workspace_sidecar=tmp_path / "ws.json"),
|
||||
)
|
||||
manager._write_workspace_sidecar(
|
||||
workspace_dir=tmp_path / "ws", pid=1, config_fingerprint="abc123"
|
||||
)
|
||||
assert json.loads((tmp_path / "ws.json").read_text())["config_fingerprint"] == (
|
||||
"abc123"
|
||||
)
|
||||
|
||||
|
||||
def test_server_config_fingerprint_tracks_relevant_fields():
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
assert base == manager._server_config_fingerprint(cfg), "must be deterministic"
|
||||
cfg.model = "some-other-model"
|
||||
assert manager._server_config_fingerprint(cfg) != base
|
||||
|
||||
|
||||
def _reuse_setup(tmp_path, monkeypatch, runtime_paths, fingerprint):
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, workspace_sidecar=tmp_path / "ws.json"),
|
||||
)
|
||||
manager._write_workspace_sidecar(
|
||||
workspace_dir=tmp_path / "A", pid=99999, config_fingerprint=fingerprint
|
||||
)
|
||||
monkeypatch.setattr(manager, "is_langgraph_dev_running", lambda **_kw: True)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
monkeypatch.setattr(manager, "_PROCESS_WORKSPACE", None)
|
||||
cfg = manager.EvoScientistConfig()
|
||||
cfg.enable_async_subagents = True
|
||||
return cfg
|
||||
|
||||
|
||||
def test_reuse_sets_drift_flag_on_fingerprint_mismatch(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
cfg = _reuse_setup(tmp_path, monkeypatch, runtime_paths, "stale-fingerprint")
|
||||
manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path / "A")
|
||||
assert manager.CONFIG_DRIFT_SINCE_LAUNCH is True
|
||||
|
||||
|
||||
def test_reuse_clears_drift_flag_on_matching_fingerprint(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
cfg = manager.EvoScientistConfig()
|
||||
cfg.enable_async_subagents = True
|
||||
fp = manager._server_config_fingerprint(cfg)
|
||||
cfg2 = _reuse_setup(tmp_path, monkeypatch, runtime_paths, fp)
|
||||
manager.ensure_langgraph_dev(cfg2, workspace_dir=tmp_path / "A")
|
||||
assert manager.CONFIG_DRIFT_SINCE_LAUNCH is False
|
||||
|
||||
|
||||
def test_stop_recorded_server_none_when_no_pid_file(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
monkeypatch.setattr(manager, "RUNTIME", runtime_paths)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
assert manager.stop_recorded_server() is None
|
||||
|
||||
|
||||
def test_stop_recorded_server_cleans_stale_files_for_dead_pid(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
pid_file = tmp_path / "pid.txt"
|
||||
sidecar = tmp_path / "ws.json"
|
||||
pid_file.write_text(str(_dead_pid()))
|
||||
sidecar.write_text(json.dumps({"workspace": "/a", "pid": 1}))
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(
|
||||
runtime_paths, pid_file=pid_file, workspace_sidecar=sidecar
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
assert manager.stop_recorded_server() is None
|
||||
assert not pid_file.exists()
|
||||
assert not sidecar.exists()
|
||||
|
||||
|
||||
def test_stop_recorded_server_refuses_foreign_process(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
victim = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
try:
|
||||
pid_file = tmp_path / "pid.txt"
|
||||
pid_file.write_text(str(victim.pid))
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, pid_file=pid_file),
|
||||
)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
assert manager.stop_recorded_server() is None
|
||||
assert victim.poll() is None, "foreign process must not be killed"
|
||||
finally:
|
||||
victim.kill()
|
||||
victim.wait()
|
||||
|
||||
|
||||
def test_stop_recorded_server_kills_owned_langgraph_process(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
victim = subprocess.Popen(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)", "langgraph"]
|
||||
)
|
||||
try:
|
||||
pid_file = tmp_path / "pid.txt"
|
||||
sidecar = tmp_path / "ws.json"
|
||||
pid_file.write_text(str(victim.pid))
|
||||
sidecar.write_text(json.dumps({"workspace": "/a", "pid": victim.pid}))
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(
|
||||
runtime_paths, pid_file=pid_file, workspace_sidecar=sidecar
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
assert manager.stop_recorded_server() == victim.pid
|
||||
assert victim.poll() is not None, "owned langgraph process must be stopped"
|
||||
assert not pid_file.exists()
|
||||
assert not sidecar.exists()
|
||||
finally:
|
||||
if victim.poll() is None:
|
||||
victim.kill()
|
||||
victim.wait()
|
||||
|
||||
|
||||
def test_stop_recorded_server_cleans_corrupt_pid_file(
|
||||
tmp_path, monkeypatch, runtime_paths
|
||||
):
|
||||
"""A corrupt PID file is cleaned up, matching the docstring's promise."""
|
||||
pid_file = tmp_path / "pid.txt"
|
||||
pid_file.write_text("not-a-pid")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, pid_file=pid_file),
|
||||
)
|
||||
monkeypatch.setattr(manager, "_PROCESS", None)
|
||||
assert manager.stop_recorded_server() is None
|
||||
assert not pid_file.exists()
|
||||
|
||||
|
||||
def test_server_config_fingerprint_tracks_dangerous_mode():
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
cfg.dangerous_mode = True
|
||||
assert manager._server_config_fingerprint(cfg) != base
|
||||
|
||||
|
||||
def test_pid_serves_port_matrix():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
assert manager._pid_serves_port(None, 6174) is False
|
||||
assert manager._pid_serves_port(True, 6174) is False
|
||||
assert manager._pid_serves_port(-1, 6174) is False
|
||||
|
||||
victim = subprocess.Popen(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)", "langgraph", "6174"]
|
||||
)
|
||||
try:
|
||||
assert manager._pid_serves_port(victim.pid, 6174) is True
|
||||
assert manager._pid_serves_port(victim.pid, 9999) is False, (
|
||||
"must not attribute a server bound to a different port"
|
||||
)
|
||||
finally:
|
||||
victim.kill()
|
||||
victim.wait()
|
||||
assert manager._pid_serves_port(victim.pid, 6174) is False, "dead pid"
|
||||
|
||||
|
||||
def test_server_config_fingerprint_covers_new_fields_by_default():
|
||||
"""The fingerprint iterates the dataclass minus an exclusion set, so
|
||||
server-affecting values din0s called out — reasoning effort, provider
|
||||
keys/base URLs — count without per-field registration."""
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
cfg.reasoning_effort = "definitely-different"
|
||||
after_effort = manager._server_config_fingerprint(cfg)
|
||||
assert after_effort != base
|
||||
cfg.anthropic_base_url = "https://proxy.example"
|
||||
assert manager._server_config_fingerprint(cfg) != after_effort
|
||||
|
||||
|
||||
def test_server_config_fingerprint_ignores_cli_only_fields():
|
||||
"""Channel/UI-side fields never reach the server — no spurious drift."""
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
cfg.show_thinking = not cfg.show_thinking
|
||||
cfg.telegram_bot_token = "tg-token"
|
||||
cfg.langgraph_dev_keepalive = True
|
||||
assert manager._server_config_fingerprint(cfg) == base
|
||||
|
||||
|
||||
def test_sidecar_records_deploy_mode(tmp_path, monkeypatch, runtime_paths):
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
dataclasses.replace(runtime_paths, workspace_sidecar=tmp_path / "ws.json"),
|
||||
)
|
||||
manager._write_workspace_sidecar(
|
||||
workspace_dir=tmp_path / "ws", pid=1, deploy_mode=False
|
||||
)
|
||||
assert json.loads((tmp_path / "ws.json").read_text())["deploy_mode"] is False
|
||||
|
||||
|
||||
def test_server_config_fingerprint_ignores_shell_allow_list():
|
||||
"""shell_allow_list is read only by CLI/channel approval resolvers —
|
||||
it never reaches the subprocess, so it must not cause drift."""
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
cfg.shell_allow_list = "git status,ls"
|
||||
assert manager._server_config_fingerprint(cfg) == base
|
||||
|
||||
|
||||
def test_server_config_fingerprint_tracks_mcp_yaml_content(tmp_path, monkeypatch):
|
||||
from EvoScientist.config import settings as settings_mod
|
||||
|
||||
monkeypatch.setattr(settings_mod, "get_config_dir", lambda: tmp_path)
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
(tmp_path / "mcp.yaml").write_text("server-a:\n transport: stdio\n")
|
||||
changed = manager._server_config_fingerprint(cfg)
|
||||
assert changed != base
|
||||
(tmp_path / "mcp.yaml").write_text("server-b:\n transport: http\n")
|
||||
assert manager._server_config_fingerprint(cfg) != changed
|
||||
|
||||
|
||||
def test_server_config_fingerprint_tracks_subagent_yaml_content(tmp_path, monkeypatch):
|
||||
fake_dir = tmp_path / "subagents"
|
||||
fake_dir.mkdir()
|
||||
(fake_dir / "research.yaml").write_text("name: research\nprompt: v1\n")
|
||||
monkeypatch.setattr(manager, "_SUBAGENTS_DIR", fake_dir)
|
||||
cfg = manager.EvoScientistConfig()
|
||||
base = manager._server_config_fingerprint(cfg)
|
||||
(fake_dir / "research.yaml").write_text("name: research\nprompt: v2\n")
|
||||
assert manager._server_config_fingerprint(cfg) != base
|
||||
|
||||
@@ -419,6 +419,10 @@ class TestEnsureLanggraphDev:
|
||||
with (
|
||||
patch.object(manager, "is_langgraph_dev_running", return_value=False),
|
||||
patch.object(manager, "start_langgraph_dev", return_value=proc) as start,
|
||||
# Intercept the real atexit hook: letting it register means the
|
||||
# callback fires at interpreter exit AFTER patches are restored,
|
||||
# deleting the REAL ~/.config/evoscientist pid/sidecar files.
|
||||
patch.object(manager.atexit, "register") as atexit_register,
|
||||
patch.object(
|
||||
manager,
|
||||
"RUNTIME",
|
||||
@@ -432,6 +436,7 @@ class TestEnsureLanggraphDev:
|
||||
|
||||
assert result is proc
|
||||
start.assert_called_once()
|
||||
atexit_register.assert_called_once_with(manager.stop_langgraph_dev, proc)
|
||||
assert manager.is_async_subagents_available() is True
|
||||
|
||||
def test_skips_when_async_and_memory_workers_disabled(
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid
|
||||
from datetime import UTC
|
||||
from unittest.mock import patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
@@ -232,6 +233,19 @@ class TestThreadFunctions(unittest.IsolatedAsyncioTestCase):
|
||||
threads = await list_threads(limit=10, include_message_count=True)
|
||||
assert "message_count" in threads[0]
|
||||
|
||||
async def test_list_threads_creates_meta_index(self):
|
||||
"""Thread listing must run off the expression index, not a blob scan."""
|
||||
async with aiosqlite.connect(self._db_path) as conn:
|
||||
await conn.execute("DROP INDEX IF EXISTS idx_evoscientist_thread_meta")
|
||||
await conn.commit()
|
||||
await list_threads(limit=10)
|
||||
async with aiosqlite.connect(self._db_path) as conn:
|
||||
async with conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||
("idx_evoscientist_thread_meta",),
|
||||
) as cur:
|
||||
assert await cur.fetchone() is not None
|
||||
|
||||
async def test_thread_exists_true(self):
|
||||
assert await thread_exists("abc12345")
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ def _make_config(
|
||||
langgraph_dev_host: str = "127.0.0.1",
|
||||
webui_port: int = 4716,
|
||||
webui_host: str = "127.0.0.1",
|
||||
langgraph_dev_keepalive: bool = False,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
default_workdir=default_workdir,
|
||||
@@ -35,6 +36,7 @@ def _make_config(
|
||||
webui_host=webui_host,
|
||||
langgraph_dev_jobs_per_worker=10,
|
||||
langgraph_dev_file_persistence=True,
|
||||
langgraph_dev_keepalive=langgraph_dev_keepalive,
|
||||
)
|
||||
|
||||
|
||||
@@ -121,7 +123,12 @@ def _run_webui_once(monkeypatch, config, *, backend_port_occupied: bool = False)
|
||||
return SimpleNamespace(pid=99999)
|
||||
|
||||
monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev)
|
||||
monkeypatch.setattr(lgm, "stop_langgraph_dev", lambda *_a, **_kw: None)
|
||||
|
||||
def _fake_stop(*_a, **_kw):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop)
|
||||
captured["stop_fn"] = _fake_stop
|
||||
|
||||
class _FakeProc:
|
||||
pid = 12345
|
||||
@@ -146,7 +153,10 @@ def _run_webui_once(monkeypatch, config, *, backend_port_occupied: bool = False)
|
||||
monkeypatch.setattr(subprocess, "Popen", _fake_popen)
|
||||
# _stop_webui shells out to taskkill on Windows — neutralize it.
|
||||
monkeypatch.setattr(webui_mod, "_stop_webui", lambda _proc: None)
|
||||
monkeypatch.setattr(atexit, "register", lambda fn, *a, **k: fn)
|
||||
captured["atexit_fns"] = []
|
||||
monkeypatch.setattr(
|
||||
atexit, "register", lambda fn, *a, **k: captured["atexit_fns"].append(fn) or fn
|
||||
)
|
||||
monkeypatch.setattr(signal, "signal", lambda _sig, _handler: lambda *a: None)
|
||||
monkeypatch.setattr(threading, "Event", _ImmediateEvent)
|
||||
|
||||
@@ -273,3 +283,21 @@ def test_no_remote_hint_when_both_exposed(monkeypatch):
|
||||
|
||||
banner = "\n".join(captured["printed"])
|
||||
assert "Remote visitors cannot reach" not in banner
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Backend keepalive
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_backend_default_registers_stop_on_exit(monkeypatch):
|
||||
"""Without keepalive the WebUI-started backend dies with the session."""
|
||||
captured = _run_webui_once(monkeypatch, _make_config())
|
||||
assert captured["stop_fn"] in captured["atexit_fns"]
|
||||
|
||||
|
||||
def test_backend_keepalive_skips_stop_registration(monkeypatch):
|
||||
"""With keepalive the backend outlives the WebUI session, so the next
|
||||
same-workspace launch reuses it instead of paying the cold boot."""
|
||||
captured = _run_webui_once(monkeypatch, _make_config(langgraph_dev_keepalive=True))
|
||||
assert captured["stop_fn"] not in captured["atexit_fns"]
|
||||
|
||||
Reference in New Issue
Block a user