Files
EvoScientist/tests/test_gateway_background_runs.py
T
dinos f2f010a350 feat(memory): observation linking (#307)
* refactor(gateway): create module for launching async/bg agents

* refactor(memory): refactor worker launch around source context & output deltas

* refactor(gateway): generalize async/bg module

* refactor(memory): revamp worker launching

* feat(memory): add observation linking

* test(memory): remove redundant test branches

* fix(memory): make 'supersedes' relation directional

* fix(memory): don't create empty project observation dirs

* fix(memory): schedule direct observations for linking

* fix(cli): wait for observation linker before shutdown

* fix(memory): block arbitrary writes to /memories

* fix(linker): remove `linked_by` attribute from frontmatter

* refactor(linker): rename base relationship to `comlpements`

* fix(cli): bump worker wait to 2m

* feat(tools): catch malformed tool calls & retry

* feat(status): add linking result to statusbar

* fix(linker): don't launch linker when observations are disabled

* fix(memory): use posix paths

* fix(watcher): call abort hook on error status

* fix(watcher): delete thread on failed run creation

* fix(watcher): preserve url

* fix(observation): record session_id, drop unused fields

* fix(memory): reject unsupported worker source types

* refactor(backends): shared memory backend builder

* fix(scheduler): resolve linker inputs outside lock

* fix(memory): dont launch workers / record observations without thread_id

* feat(memory): include related observations in tool results

* fix(memory): skip malformed observation frontmatter

* revert(tools): drop tool error handling changes from this PR

* fix(memory): serialize observation link writes

* fix(memory): queue observations written by aborted workers

* fix(memory): track observation linker launch handoff

* fix(memory): resolve cross-project related observations

* fix(status): avoid recounting reason-only link updates

* fix(memory): avoid rereading file for content

* fix(linker): use neutral prose for bidirectional reasons

* test(memory): coverage for aborted/failed launches

* test(memory): cleanup & helpers

* feat(linker): add observations index hint
2026-06-26 22:20:52 +01:00

345 lines
10 KiB
Python

from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import EvoScientist.gateway.background_runs as background_runs
def _run_payload(thread_id: str) -> background_runs.BackgroundRunPayload:
return {
"assistant_id": "graph-1",
"input": {"messages": []},
"metadata": {"run_kind": "test"},
"config": {"configurable": {"thread_id": thread_id}},
}
def _request(
*,
run_payload=_run_payload,
thread_metadata: dict[str, str] | None = None,
) -> background_runs.BackgroundRunRequest:
return background_runs.BackgroundRunRequest(
graph_id="graph-1",
run_payload=run_payload,
thread_metadata=thread_metadata,
url="http://x",
name="test worker",
)
def _install_sync_launcher(
monkeypatch,
*,
run_create_result: object | None = None,
run_create_error: Exception | None = None,
) -> MagicMock:
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: True,
)
fake_client = MagicMock()
fake_client.threads.create.return_value = {"thread_id": "thread-1"}
if run_create_error is not None:
fake_client.runs.create.side_effect = run_create_error
else:
fake_client.runs.create.return_value = run_create_result or {
"run_id": "run-1",
"status": "pending",
}
monkeypatch.setattr("langgraph_sdk.get_sync_client", lambda **_kwargs: fake_client)
return fake_client
def _install_sync_watcher(monkeypatch, *, status: str | Exception, deleted: list[str]):
class _Runs:
def get(self, **_kwargs):
if isinstance(status, Exception):
raise status
return {"status": status}
class _Threads:
def delete(self, thread_id: str):
deleted.append(thread_id)
monkeypatch.setattr(
"langgraph_sdk.get_sync_client",
lambda **_kwargs: SimpleNamespace(runs=_Runs(), threads=_Threads()),
)
def _watch_sync(
*,
hooks: background_runs.BackgroundRunHooks,
max_poll_failures: int = 3,
) -> None:
background_runs.watch_background_run_sync(
url="http://x",
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=hooks,
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
max_poll_failures=max_poll_failures,
),
)
def test_launch_background_run_submits_run_and_invokes_hooks(monkeypatch):
fake_client = _install_sync_launcher(
monkeypatch,
run_create_result={
"run_id": "run-1",
"status": "pending",
},
)
payload_calls: list[str] = []
before_calls: list[str] = []
started: list[background_runs.BackgroundRun] = []
watchers: list[background_runs.BackgroundRun] = []
def build_payload(thread_id: str) -> background_runs.BackgroundRunPayload:
payload_calls.append(thread_id)
return {
"assistant_id": "graph-1",
"input": {"messages": [{"role": "user", "content": "go"}]},
"metadata": {"run_kind": "test"},
"config": {"configurable": {"thread_id": thread_id}},
}
handle = background_runs.launch_background_run(
_request(
run_payload=build_payload,
thread_metadata={"thread_kind": "test"},
),
hooks=background_runs.BackgroundRunHooks(
on_before_run=before_calls.append,
on_started=started.append,
),
spawn_status_watcher=watchers.append,
)
assert handle is not None
assert handle.thread_id == "thread-1"
assert handle.run_id == "run-1"
assert payload_calls == ["thread-1"]
assert before_calls == ["thread-1"]
assert started == [handle]
assert watchers == [handle]
fake_client.threads.create.assert_called_once_with(
graph_id="graph-1",
metadata={"thread_kind": "test"},
)
fake_client.runs.create.assert_called_once_with(
thread_id="thread-1",
assistant_id="graph-1",
input={"messages": [{"role": "user", "content": "go"}]},
metadata={"run_kind": "test"},
config={"configurable": {"thread_id": "thread-1"}},
)
def test_launch_background_run_routes_watcher_start_failure_to_hook(monkeypatch):
_install_sync_launcher(monkeypatch)
watcher_failures: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = []
def fail_to_start_watcher(_run: background_runs.BackgroundRun) -> None:
raise RuntimeError("watcher failed")
handle = background_runs.launch_background_run(
_request(),
hooks=background_runs.BackgroundRunHooks(
on_watcher_start_failed=watcher_failures.append,
on_aborted=aborted.append,
),
spawn_status_watcher=fail_to_start_watcher,
)
assert handle is not None
assert watcher_failures == [handle]
assert aborted == []
def test_launch_background_run_deletes_thread_when_run_creation_fails(monkeypatch):
fake_client = _install_sync_launcher(
monkeypatch,
run_create_error=RuntimeError("run creation failed"),
)
with pytest.raises(RuntimeError, match="run creation failed"):
background_runs.launch_background_run(_request())
fake_client.threads.delete.assert_called_once_with("thread-1")
def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
monkeypatch,
):
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: True,
)
deleted: list[str] = []
class _Threads:
async def create(self, **_kwargs):
return {"thread_id": "thread-1"}
async def delete(self, thread_id: str):
deleted.append(thread_id)
class _Runs:
async def create(self, **_kwargs):
raise RuntimeError("run creation failed")
monkeypatch.setattr(
"langgraph_sdk.get_client",
lambda **_kwargs: SimpleNamespace(threads=_Threads(), runs=_Runs()),
)
async def run() -> None:
with pytest.raises(RuntimeError, match="run creation failed"):
await background_runs.alaunch_background_run(_request())
asyncio.run(run())
assert deleted == ["thread-1"]
@pytest.mark.parametrize(
("status", "expected_finished", "expected_aborted"),
[
("success", ["run-1"], []),
("error", [], ["run-1"]),
],
)
def test_sync_status_watcher_handles_terminal_statuses(
monkeypatch,
status: str,
expected_finished: list[str],
expected_aborted: list[str],
):
finished: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = []
deleted: list[str] = []
_install_sync_watcher(monkeypatch, status=status, deleted=deleted)
_watch_sync(
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
on_aborted=aborted.append,
),
)
assert [run.run_id for run in finished] == expected_finished
assert [run.run_id for run in aborted] == expected_aborted
assert deleted == ["thread-1"]
@pytest.mark.parametrize(
("use_status_unknown", "expected_unknown", "expected_aborted"),
[
(False, [], ["run-1"]),
(True, ["run-1"], []),
],
)
def test_sync_status_watcher_preserves_thread_on_poll_failure(
monkeypatch,
use_status_unknown: bool,
expected_unknown: list[str],
expected_aborted: list[str],
):
status_unknown: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = []
deleted: list[str] = []
_install_sync_watcher(
monkeypatch,
status=RuntimeError("poll failed"),
deleted=deleted,
)
_watch_sync(
hooks=background_runs.BackgroundRunHooks(
on_status_unknown=status_unknown.append if use_status_unknown else None,
on_aborted=aborted.append,
),
max_poll_failures=1,
)
assert [run.run_id for run in status_unknown] == expected_unknown
assert [run.run_id for run in aborted] == expected_aborted
assert deleted == []
def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
finished: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = []
deleted: list[str] = []
class _Runs:
async def get(self, **_kwargs):
return {"status": "error"}
class _Threads:
async def delete(self, thread_id: str):
deleted.append(thread_id)
async def run() -> None:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
on_aborted=aborted.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
asyncio.run(run())
assert finished == []
assert [run.run_id for run in aborted] == ["run-1"]
assert deleted == ["thread-1"]
def test_async_status_watcher_preserves_run_url():
finished: list[background_runs.BackgroundRun] = []
class _Runs:
async def get(self, **_kwargs):
return {"status": "success"}
class _Threads:
async def delete(self, _thread_id: str):
return None
async def run() -> None:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
url="http://worker.example",
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
asyncio.run(run())
assert [run.url for run in finished] == ["http://worker.example"]