Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

393 lines
13 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional
import pytest
from _pytest.logging import LogCaptureFixture
from agentlightning.execution.events import ExecutionEvent, ThreadingEvent
from agentlightning.execution.shared_memory import SharedMemoryExecutionStrategy
from agentlightning.store.base import LightningStore
from ..store.dummy_store import DummyLightningStore, minimal_dummy_store
@pytest.fixture
def store() -> DummyLightningStore:
return minimal_dummy_store()
def test_env_managed_store(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AGL_MANAGED_STORE", "false")
strat = SharedMemoryExecutionStrategy()
assert strat.managed_store is False
def test_explicit_managed_store_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AGL_MANAGED_STORE", "0")
strat = SharedMemoryExecutionStrategy(managed_store=True)
assert strat.managed_store is True
def test_execute_uses_unmanaged_store_directly(store: DummyLightningStore) -> None:
strat = SharedMemoryExecutionStrategy(managed_store=False)
used: Dict[str, LightningStore] = {}
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
used["algo"] = store
event.set()
async def runner(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
used["runner"] = store
while not event.is_set():
await asyncio.sleep(0.01)
strat.execute(algo, runner, store)
assert used["algo"] is store
assert used["runner"] is store
def tiny_sleep(seconds: float) -> float:
"""Sleep a tiny bit and return the elapsed time (monotonic)."""
start = time.monotonic()
time.sleep(seconds)
return time.monotonic() - start
# Helper bundles for tests
def make_cooperative_algorithm(
started: List[str],
finished: List[str],
poll_delay: float = 0.005,
):
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
started.append("algo")
# cooperatively exit when asked
while not event.is_set():
await asyncio.sleep(poll_delay)
finished.append("algo")
return algo
def make_cooperative_runner(
started: List[int],
finished: List[int],
poll_delay: float = 0.005,
):
async def runner(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
started.append(worker_id)
while not event.is_set():
await asyncio.sleep(poll_delay)
finished.append(worker_id)
return runner
def make_sleepy_coro(delay: float, result: Any):
async def _coro():
await asyncio.sleep(delay)
return result
return _coro()
def make_never_polls_coro(total_time: float):
"""A coro that just sleeps in chunks; cancellation lands at await points only."""
async def _coro():
end = time.monotonic() + total_time
while time.monotonic() < end:
await asyncio.sleep(0.05)
return "done"
return _coro()
def make_slow_cleanup_on_cancel_coro(cleanup_time: float):
"""On cancellation, do slow cleanup before exiting (tests second-chance timeout)."""
async def _coro():
try:
# Idle until canceled
await asyncio.Event().wait()
except asyncio.CancelledError:
await asyncio.sleep(cleanup_time)
raise
return _coro()
# Unit tests: _run_until_completed_or_canceled
def test_run_until_completes_naturally(caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=0.05, join_timeout=0.2)
evt = ThreadingEvent()
result = asyncio.run(
strat._run_until_completed_or_canceled(make_sleepy_coro(0.02, "ok"), evt) # pyright: ignore[reportPrivateUsage]
)
assert result == "ok"
def test_run_until_stops_gracefully_with_event(caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=0.05, join_timeout=0.2)
evt = ThreadingEvent()
async def cooperative(event: ExecutionEvent):
# Stop promptly after event flips
while not event.is_set():
await asyncio.sleep(0.005)
return "bye"
# Set the stop event before we start to exercise the "finish during grace" path
evt.set()
result = asyncio.run(
strat._run_until_completed_or_canceled(cooperative(evt), evt) # pyright: ignore[reportPrivateUsage]
)
assert result == "bye"
# Should have logged that the bundle finished during grace period
assert any("finished by itself during grace period" in rec.message for rec in caplog.records)
def test_run_until_cancels_after_grace_if_not_quick_to_stop(caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
# Grace: 30ms; the task won't stop within that, so cancel fires; then second-chance wait succeeds.
graceful = 0.03
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=graceful, join_timeout=0.2)
evt = ThreadingEvent()
async def slow_to_notice():
# Doesn't poll stop_evt; cancellation must interrupt sleeps
await asyncio.sleep(1.0)
async def runner():
# flip stop after a short time
asyncio.get_running_loop().call_later(0.01, evt.set)
return await strat._run_until_completed_or_canceled( # pyright: ignore[reportPrivateUsage]
slow_to_notice(), evt
)
t0 = time.monotonic()
result = asyncio.run(runner())
elapsed = time.monotonic() - t0
# We don't get a "result" because the task was canceled
assert result is None
# Should be roughly >= grace window, but way less than full 1.0s sleep
assert elapsed >= graceful * 0.9
assert elapsed < 0.5
assert any("Graceful delay elapsed; canceling bundle task..." in rec.message for rec in caplog.records)
def test_run_until_second_chance_timeout_logs_and_returns(caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
# The task takes longer to clean up than graceful_delay; we hit the second-chance timeout branch.
graceful = 0.03
cleanup = 0.08
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=graceful, join_timeout=0.2)
evt = ThreadingEvent()
async def runner():
# Schedule stop, then run a task that sleeps extra on cancel
asyncio.get_running_loop().call_later(0.005, evt.set)
return await strat._run_until_completed_or_canceled( # pyright: ignore[reportPrivateUsage]
make_slow_cleanup_on_cancel_coro(cleanup), evt
)
t0 = time.monotonic()
result = asyncio.run(runner())
elapsed = time.monotonic() - t0
# No result expected because the task gets canceled; we abandon await after timeout.
assert result is None
assert elapsed >= graceful # we at least burned the grace + a bit
assert any("did not stop after cancellation; abandoning task" in rec.message for rec in caplog.records)
# Unit tests: _run_algorithm and _run_runner
def test_run_algorithm_sets_stop_on_exception(store: DummyLightningStore):
strat = SharedMemoryExecutionStrategy()
evt = ThreadingEvent()
async def boom(store: LightningStore, event: ExecutionEvent) -> None:
await asyncio.sleep(0.005)
raise ValueError("algo crash")
with pytest.raises(ValueError):
strat._run_algorithm(boom, store, evt, None) # pyright: ignore[reportPrivateUsage]
assert evt.is_set(), "stop_evt must be set when algorithm raises"
def test_run_runner_sets_stop_on_exception(store: DummyLightningStore):
strat = SharedMemoryExecutionStrategy()
evt = ThreadingEvent()
async def boom(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
await asyncio.sleep(0.005)
raise RuntimeError("runner crash")
with pytest.raises(RuntimeError):
strat._run_runner(boom, store, 0, evt, None) # pyright: ignore[reportPrivateUsage]
assert evt.is_set(), "stop_evt must be set when a runner raises"
# Integration tests: execute(...)
def test_execute_main_algorithm_normal_stop_sets_event(store: DummyLightningStore):
started_r: List[int] = []
finished_r: List[int] = []
started_a: List[str] = []
finished_a: List[str] = []
strat = SharedMemoryExecutionStrategy(n_runners=2, main_thread="algorithm", graceful_delay=0.02, join_timeout=0.2)
runner = make_cooperative_runner(started_r, finished_r, poll_delay=0.005)
# Algorithm finishes quickly; then execute() should set stop_evt for runners.
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
started_a.append("algo")
await asyncio.sleep(0.02)
finished_a.append("algo")
strat.execute(algo, runner, store)
# Runners should have started and finished due to stop_evt set by execute()
assert sorted(started_r) == [0, 1]
assert sorted(finished_r) == [0, 1]
assert started_a == ["algo"]
assert finished_a == ["algo"]
def test_execute_main_runner_waits_for_algorithm_natural_finish(store: DummyLightningStore):
# Policy: when main_thread='runner' and runner finishes, do NOT set stop; wait for algo to finish.
started_r: List[int] = []
finished_r: List[int] = []
started_a: List[str] = []
finished_a: List[str] = []
captured_evt: List[Optional[Any]] = [None]
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=0.02, join_timeout=0.2)
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
captured_evt[0] = event
started_a.append("algo")
await asyncio.sleep(0.05) # natural finish
finished_a.append("algo")
async def runner(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
started_r.append(worker_id)
await asyncio.sleep(0.01) # finish quickly
finished_r.append(worker_id)
strat.execute(algo, runner, store)
assert started_r == [0]
assert finished_r == [0]
assert started_a == ["algo"]
assert finished_a == ["algo"]
# Verify execute() did not set stop_evt just because runner ended.
assert captured_evt[0] is not None and captured_evt[0].is_set() is False
def test_execute_runner_crash_propagates_and_stops_algorithm(store: DummyLightningStore):
# Runner raises; algorithm should see stop_evt and exit.
started_a: List[str] = []
finished_a: List[str] = []
saw_stop: List[bool] = []
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="algorithm", graceful_delay=0.02, join_timeout=0.3)
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
started_a.append("algo")
# wait until stop evt set (by runner crash)
while not event.is_set():
await asyncio.sleep(0.005)
saw_stop.append(True)
finished_a.append("algo")
async def bad_runner(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
await asyncio.sleep(0.02)
raise RuntimeError("boom")
with pytest.raises(RuntimeError):
strat.execute(algo, bad_runner, store)
assert started_a == ["algo"]
assert saw_stop == [True]
assert finished_a == ["algo"]
def test_execute_ctrl_c_on_algorithm_stops_runners(store: DummyLightningStore, caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
# Simulate Ctrl+C by having the algorithm raise KeyboardInterrupt on the main thread.
runner_started: List[int] = []
runner_finished: List[int] = []
strat = SharedMemoryExecutionStrategy(n_runners=2, main_thread="algorithm", graceful_delay=0.02, join_timeout=0.3)
async def algo_kbi(store: LightningStore, event: ExecutionEvent) -> None:
await asyncio.sleep(0.01)
raise KeyboardInterrupt()
async def runner(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
runner_started.append(worker_id)
while not event.is_set():
await asyncio.sleep(0.005)
runner_finished.append(worker_id)
strat.execute(algo_kbi, runner, store)
# Runners should have started and then exited due to stop signal
assert sorted(runner_started) == [0, 1]
assert sorted(runner_finished) == [0, 1]
assert any("KeyboardInterrupt received on main thread" in rec.message for rec in caplog.records)
def test_execute_ctrl_c_on_runner_stops_algorithm(store: DummyLightningStore, caplog: LogCaptureFixture):
caplog.set_level(logging.DEBUG)
# Simulate Ctrl+C on the main thread when main_thread='runner'
algo_started: List[str] = []
algo_finished: List[str] = []
strat = SharedMemoryExecutionStrategy(n_runners=1, main_thread="runner", graceful_delay=0.02, join_timeout=0.3)
async def algo(store: LightningStore, event: ExecutionEvent) -> None:
algo_started.append("a")
# Await stop_evt after KBI from runner
while not event.is_set():
await asyncio.sleep(0.005)
algo_finished.append("a")
async def runner_kbi(store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
await asyncio.sleep(0.01)
raise KeyboardInterrupt()
strat.execute(algo, runner_kbi, store)
assert algo_started == ["a"]
assert algo_finished == ["a"] # algorithm should finish after stop_evt set by KBI handler