a13904185d
* feat: implement configurable sandbox execute timeout and enhance recovery instructions * feat: add background process management tools and middleware for sandbox execution * feat: enhance background process management with completion notifications and deduplication * feat: enhance sandbox execution timeout validation and update related messages * feat: enhance background process management with thread-specific completion notifications and HITL approval handling * test: assert completion notification waits for process finish timestamp
153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""Tests for EvoScientist.background — the background-process manager."""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from EvoScientist import background as bg
|
|
|
|
|
|
def _wait_until(predicate, timeout=4.0, interval=0.05):
|
|
"""Poll ``predicate`` until true or ``timeout`` — avoids flaky fixed sleeps on slow CI."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(interval)
|
|
return False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_registry():
|
|
"""Isolate each test: clear the module-global registry and reap leftovers."""
|
|
bg._PROCESSES.clear()
|
|
yield
|
|
for proc in list(bg._PROCESSES.values()):
|
|
try:
|
|
proc.popen.kill()
|
|
except Exception:
|
|
pass
|
|
bg._PROCESSES.clear()
|
|
|
|
|
|
def test_launch_returns_id_and_creates_log(tmp_path):
|
|
pid = bg.launch("echo hi", str(tmp_path))
|
|
assert pid in bg._PROCESSES
|
|
assert (tmp_path / ".bg_processes" / f"{pid}.log").exists()
|
|
|
|
|
|
def test_status_running_then_exited(tmp_path):
|
|
pid = bg.launch("sleep 1", str(tmp_path))
|
|
assert "RUNNING" in bg.status(pid)
|
|
assert _wait_until(lambda: "EXITED" in bg.status(pid))
|
|
out = bg.status(pid)
|
|
assert "EXITED" in out
|
|
assert "code 0" in out
|
|
|
|
|
|
def test_output_captured_in_status(tmp_path):
|
|
pid = bg.launch("echo hello-from-bg", str(tmp_path))
|
|
assert _wait_until(lambda: "hello-from-bg" in bg.status(pid))
|
|
|
|
|
|
def test_large_log_returns_truncated_tail(tmp_path):
|
|
"""status() preserves the truncation contract for a large log (output shape, not I/O)."""
|
|
pid = bg.launch("true", str(tmp_path))
|
|
log_path = tmp_path / ".bg_processes" / f"{pid}.log"
|
|
log_path.write_bytes(b"A" * 5000 + b"TAIL_MARKER")
|
|
out = bg.status(pid, tail_bytes=64)
|
|
assert "...(truncated)..." in out
|
|
assert "TAIL_MARKER" in out
|
|
assert "A" * 5000 not in out # the head was not loaded
|
|
|
|
|
|
def test_stop_kills_running_process(tmp_path):
|
|
pid = bg.launch("sleep 600", str(tmp_path))
|
|
assert "RUNNING" in bg.status(pid)
|
|
out = bg.stop(pid)
|
|
assert "Stopped" in out
|
|
assert bg._PROCESSES[pid].popen.poll() is not None # actually terminated
|
|
|
|
|
|
def test_stop_already_finished_is_graceful(tmp_path):
|
|
pid = bg.launch("true", str(tmp_path))
|
|
assert _wait_until(lambda: bg._PROCESSES[pid].popen.poll() is not None)
|
|
assert "already finished" in bg.stop(pid)
|
|
|
|
|
|
def test_exited_elapsed_is_frozen(tmp_path):
|
|
"""Elapsed for an exited process freezes at its runtime, it must not keep growing."""
|
|
pid = bg.launch("true", str(tmp_path))
|
|
assert _wait_until(lambda: bg._PROCESSES[pid].finished_ts is not None)
|
|
bg.status(pid) # observe exit -> records finished_ts
|
|
proc = bg._PROCESSES[pid]
|
|
assert proc.finished_ts is not None
|
|
first = bg._elapsed(proc)
|
|
time.sleep(1.1) # intentional: prove elapsed stays frozen, not ticking up
|
|
assert bg._elapsed(proc) == first
|
|
|
|
|
|
def test_watcher_records_exit_without_polling(tmp_path):
|
|
"""The daemon watcher records exit on its own (no status() call needed)."""
|
|
pid = bg.launch("true", str(tmp_path))
|
|
assert _wait_until(lambda: bg._PROCESSES[pid].finished_ts is not None)
|
|
proc = bg._PROCESSES[pid]
|
|
assert proc.finished_ts is not None
|
|
assert proc.returncode == 0
|
|
|
|
|
|
def test_on_exit_callback_fires(tmp_path):
|
|
"""on_exit is invoked with the BgProcess once the process exits."""
|
|
fired = {}
|
|
|
|
def cb(proc):
|
|
fired["pid"] = proc.process_id
|
|
fired["rc"] = proc.returncode
|
|
|
|
pid = bg.launch("true", str(tmp_path), on_exit=cb)
|
|
assert _wait_until(lambda: fired.get("pid") == pid and fired.get("rc") == 0)
|
|
assert fired.get("pid") == pid
|
|
assert fired.get("rc") == 0
|
|
|
|
|
|
def test_unknown_id_errors_gracefully():
|
|
assert "No such background process" in bg.status("deadbeef")
|
|
assert "No such background process" in bg.stop("deadbeef")
|
|
|
|
|
|
def test_list_all(tmp_path):
|
|
assert "No background processes" in bg.list_all()
|
|
pid = bg.launch("sleep 1", str(tmp_path))
|
|
listing = bg.list_all()
|
|
assert pid in listing
|
|
assert "RUNNING" in listing
|
|
|
|
|
|
def test_list_all_scopes_to_origin_thread(tmp_path):
|
|
"""list_all defaults to the launching session; include_all sees every session."""
|
|
pid_a = bg.launch("sleep 1", str(tmp_path), origin_thread_id="A")
|
|
pid_b = bg.launch("sleep 1", str(tmp_path), origin_thread_id="B")
|
|
listing_a = bg.list_all("A")
|
|
assert pid_a in listing_a
|
|
assert pid_b not in listing_a # B's process is hidden from session A
|
|
everything = bg.list_all("A", include_all=True)
|
|
assert pid_a in everything
|
|
assert pid_b in everything
|
|
|
|
|
|
def test_list_all_hints_at_other_sessions(tmp_path):
|
|
"""A session with no processes of its own is told others exist."""
|
|
bg.launch("sleep 1", str(tmp_path), origin_thread_id="A")
|
|
out = bg.list_all("B") # a different session
|
|
assert "other sessions" in out
|
|
assert "all_threads=True" in out
|
|
|
|
|
|
def test_dedup_is_per_thread(tmp_path):
|
|
"""A check from one session must not suppress another session's completion ping."""
|
|
pid = bg.launch("true", str(tmp_path), origin_thread_id="A")
|
|
assert _wait_until(lambda: bg._PROCESSES[pid].finished_ts is not None)
|
|
bg.status(pid, thread_id="B") # a DIFFERENT session inspects it
|
|
assert bg.was_observed_done(pid, "B") is True # B saw it
|
|
assert bg.was_observed_done(pid, "A") is False # launcher A did not -> still notify
|