Files
microsoft--agent-lightning/agentlightning/execution/shared_memory.py
T
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

283 lines
12 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import threading
from contextlib import suppress
from queue import SimpleQueue
from typing import Any, Awaitable, Callable, List, Literal, Optional, Tuple
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var
from agentlightning.store.base import LightningStore
from agentlightning.store.threading import LightningStoreThreaded
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
from .events import ExecutionEvent, ThreadingEvent
logger = logging.getLogger(__name__)
class SharedMemoryExecutionStrategy(ExecutionStrategy):
"""Execute bundles in a single process with cooperative worker threads.
Stop Model:
- All bundles share one [`ThreadingEvent`][agentlightning.ThreadingEvent]
named `stop_evt`.
- Only the main thread receives `KeyboardInterrupt`. When Ctrl+C occurs we
set `stop_evt`.
- Any exception raised inside a bundle sets `stop_evt` so other threads can
unwind cooperatively.
- Once the bundle running on the main thread exits successfully the
treatment depends on `main_thread`:
- `"algorithm"`: the runners are asked to stop by setting `stop_evt`.
- `"runner"`: the algorithm keeps running until it exits naturally.
- Background threads are marked as daemons. We join them briefly and log any
stragglers before shutting down.
!!! note
Signals other than `SIGINT` (such as `SIGTERM`) are not intercepted;
Python's default behavior for those signals is preserved.
"""
alias: str = "shm"
def __init__(
self,
n_runners: int = 1,
main_thread: Literal["algorithm", "runner"] = "runner",
join_timeout: float = 15.0,
graceful_delay: float = 5.0,
poll_interval: float = 0.05,
managed_store: bool | None = None,
) -> None:
if main_thread not in ("algorithm", "runner"):
raise ValueError("main_thread must be 'algorithm' or 'runner'")
if main_thread == "runner" and n_runners != 1:
raise ValueError(
"When main_thread is 'runner', n_runners must be 1. "
"Either use 'algorithm' on the main thread or set n_runners to 1."
)
self.n_runners = n_runners
self.main_thread = main_thread
self.join_timeout = join_timeout
self.graceful_delay = graceful_delay
self.poll_interval = poll_interval
self.managed_store = resolve_bool_env_var(
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
)
async def _run_until_completed_or_canceled(self, coro: Awaitable[Any], stop_evt: ExecutionEvent) -> Any:
"""Run `coro` until it finishes or a cooperative stop is requested.
Control flow:
1. Start the bundle coroutine as `task`.
2. Launch a watcher that polls `stop_evt` without blocking the loop.
3. When the stop event flips:
a. Give the bundle `graceful_delay` seconds to finish on its own,
because well-behaved bundles will check the event and return.
b. Cancel the bundle task if it is still running after the grace
period.
4. Await both tasks and swallow `CancelledError` where appropriate.
This is a *backup* mechanism for bundles that might not poll the event
frequently; cooperative shutdown (checking `stop_evt` inside the
bundle) remains the preferred approach.
"""
task: asyncio.Task[Any] = asyncio.create_task(coro) # type: ignore
task_exception: Optional[BaseException] = None
async def watcher() -> None:
# Poll the threading event without blocking the event loop. Using a
# background thread via ``asyncio.to_thread`` makes cancellation
# difficult because ``ThreadingEvent.wait`` is not interruptible.
# Instead we cooperatively check the flag from the loop so the
# watcher task stays cancellable and tests don't hang when the
# bundle finishes naturally before the stop event is set.
while not stop_evt.is_set():
await asyncio.sleep(self.poll_interval)
# Grace period: let a cooperative bundle exit on its own.
try:
# At this point of waiting, the main task should already see the stop event.
await asyncio.wait_for(asyncio.shield(task), timeout=self.graceful_delay) # type: ignore
logger.debug("Bundle finished by itself during grace period.")
return # bundle finished by itself during grace period
except asyncio.TimeoutError:
# Still running after the grace window.
pass
except asyncio.CancelledError:
# If someone else canceled the task already, we're done.
logger.debug("Bundle already canceled by someone else; exiting watcher.")
return
# Still running after the grace window: cancel it.
if not task.done():
logger.debug("Graceful delay elapsed; canceling bundle task...")
task.cancel()
watcher_task = asyncio.create_task(watcher())
result: Any = None
try:
# We don't wait on FIRST_COMPLETED here, because we want the watcher
# to be able to grant a grace window after stop_evt flips.
await asyncio.wait(
{task, watcher_task}, return_when=asyncio.FIRST_COMPLETED
) # pyright: ignore[reportUnknownArgumentType]
finally:
# If the main task hasn't completed yet (e.g., watcher scheduled cancel),
# finish the cancellation handshake.
if not task.done():
try:
await asyncio.wait_for(task, timeout=self.graceful_delay) # second chance
except asyncio.TimeoutError:
logger.error(
"Bundle task did not stop after cancellation; abandoning task."
"This thread could live until the process exits."
)
# We return without awaiting it. asyncio.run will still try to cancel
# pending tasks on loop close; if the task ignores cancellation, this
# thread may still stick. It's the best we can do in Python.
# We don't raise an exception here, but the thread could be a zombie.
return result
else:
# Task completed naturally; retrieve result.
try:
result = await task # type: ignore
except asyncio.CancelledError:
pass
except BaseException as exc:
task_exception = exc
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await watcher_task
if task_exception is not None:
raise task_exception
return result # type: ignore
def _run_algorithm(
self,
algorithm: AlgorithmBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(algorithm(store, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Algorithm bundle canceled due to stop signal.")
except BaseException as exc:
logger.exception("Algorithm bundle crashed; signaling stop to others.")
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def _run_runner(
self,
runner: RunnerBundle,
store: LightningStore,
worker_id: int,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(runner(store, worker_id, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Runner bundle (worker_id=%s) canceled due to stop signal.", worker_id)
except BaseException as exc:
logger.exception("Runner bundle crashed (worker_id=%s); signaling stop to others.", worker_id)
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
logger.info(
"Starting shm execution with %d runner(s); main thread runs '%s'",
self.n_runners,
self.main_thread,
)
# Create stop event and thread-safe store.
stop_evt = ThreadingEvent()
if self.managed_store:
thread_safe_store = LightningStoreThreaded(store)
else:
thread_safe_store = store
thread_exceptions: SimpleQueue[BaseException] = SimpleQueue()
raised_from_thread: Optional[BaseException] = None
def make_thread(name: str, target: Callable[..., Any], args: Tuple[Any, ...]) -> threading.Thread:
t = threading.Thread(name=name, target=target, args=args, daemon=True)
t.start()
return t
threads: List[threading.Thread] = []
try:
if self.main_thread == "algorithm":
# Start runner threads; algorithm runs on main thread.
for i in range(self.n_runners):
thread = make_thread(
name=f"runner-{i}",
target=self._run_runner,
args=(runner, thread_safe_store, i, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_algorithm(algorithm, thread_safe_store, stop_evt, None)
# If algo finishes naturally, request runners to stop.
stop_evt.set()
else: # main_thread == "runner"
# Start algorithm in background; runner runs on main thread.
thread = make_thread(
name="algorithm",
target=self._run_algorithm,
args=(algorithm, thread_safe_store, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_runner(runner, thread_safe_store, 0, stop_evt, None)
# If runner finishes naturally, WAIT FOR ALGORITHM TO FINISH.
thread.join()
if not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
except KeyboardInterrupt:
logger.warning("KeyboardInterrupt received on main thread; initiating cooperative shutdown...")
stop_evt.set()
finally:
# Attempt a clean join; if some threads don't comply, log and move on.
for t in threads:
logger.debug("Joining thread %s...", t.name)
t.join(timeout=self.join_timeout)
alive = [t.name for t in threads if t.is_alive()]
if alive:
logger.error(
"Threads still alive after %.1fs: %s. They are daemons; continuing shutdown.",
self.join_timeout,
", ".join(alive),
)
if raised_from_thread is None and not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
if raised_from_thread is not None:
raise raised_from_thread