chore: import upstream snapshot with attribution
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .base import ExecutionStrategy
|
||||
from .client_server import ClientServerExecutionStrategy
|
||||
from .events import ExecutionEvent, MultiprocessingEvent, ThreadingEvent
|
||||
from .shared_memory import SharedMemoryExecutionStrategy
|
||||
|
||||
__all__ = [
|
||||
"ExecutionStrategy",
|
||||
"ClientServerExecutionStrategy",
|
||||
"ExecutionEvent",
|
||||
"ThreadingEvent",
|
||||
"MultiprocessingEvent",
|
||||
"SharedMemoryExecutionStrategy",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from agentlightning.store.base import LightningStore
|
||||
|
||||
from .events import ExecutionEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlgorithmBundle(Protocol):
|
||||
"""Callable bundle produced by [`Trainer`][agentlightning.Trainer].
|
||||
|
||||
Execution strategies treat the returned coroutine as opaque, only providing
|
||||
the shared store instance and cooperative stop event. Bundles typically
|
||||
encapsulate algorithm setup plus adapter and LLM proxy, etc.
|
||||
"""
|
||||
|
||||
async def __call__(self, store: LightningStore, event: ExecutionEvent) -> None:
|
||||
"""Execute algorithm logic using ``store`` until completion or stop."""
|
||||
|
||||
|
||||
class RunnerBundle(Protocol):
|
||||
"""Callable bundle wrapping runner setup and the worker loop, as opposed to the
|
||||
[`AlgorithmBundle`][agentlightning.AlgorithmBundle]."""
|
||||
|
||||
async def __call__(self, store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
|
||||
"""Execute runner logic for ``worker_id`` using ``store`` and ``event``."""
|
||||
|
||||
|
||||
class ExecutionStrategy:
|
||||
"""Coordinate algorithm and runner bundles within a single process abstraction.
|
||||
|
||||
Strategies decide how many worker bundles to launch, whether to communicate
|
||||
through shared memory or an HTTP boundary, and how to react to shutdown
|
||||
signals. They intentionally avoid inspecting the bundle internals; instead,
|
||||
each bundle remains responsible for its own scheduling semantics.
|
||||
|
||||
!!! note
|
||||
Implementations must honor the [execute()][agentlightning.ExecutionStrategy.execute]
|
||||
contract by propagating `KeyboardInterrupt` and ensuring resources are
|
||||
released when an error occurs on either side of the algorithm/runner
|
||||
pair.
|
||||
"""
|
||||
|
||||
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
|
||||
"""Run the provided bundles using the configured orchestration model.
|
||||
|
||||
Args:
|
||||
algorithm: Callable bundle responsible for algorithm execution.
|
||||
runner: Callable bundle for runner workers.
|
||||
store: Concrete [`LightningStore`][agentlightning.LightningStore]
|
||||
shared across bundles.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must provide the orchestration
|
||||
implementation.
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,443 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from multiprocessing.context import BaseContext
|
||||
from typing import Callable, Iterable, Literal, cast
|
||||
|
||||
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var, resolve_int_env_var, resolve_str_env_var
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
|
||||
|
||||
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
|
||||
from .events import ExecutionEvent, MultiprocessingEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
"""Run algorithm and runner bundles as separate processes over HTTP.
|
||||
|
||||
Execution Roles:
|
||||
|
||||
- `"algorithm"`: Start [`LightningStoreServer`][agentlightning.LightningStoreServer]
|
||||
in-process and execute the algorithm bundle against it.
|
||||
- `"runner"`: Connect to an existing server with
|
||||
[`LightningStoreClient`][agentlightning.LightningStoreClient] and run the
|
||||
runner bundle locally (spawning multiple processes when requested).
|
||||
- `"both"`: Spawn runner processes first, then execute the algorithm and
|
||||
server on the same machine. This mode orchestrates the full loop locally.
|
||||
|
||||
When `role == "both"` you may choose which side runs on the main process
|
||||
via `main_process`. The runner-on-main option is limited to
|
||||
`n_runners == 1` because each additional runner requires its own event
|
||||
loop and process.
|
||||
|
||||
!!! warning
|
||||
When `main_process == "runner"` the algorithm and HTTP server execute
|
||||
in a child process. Store mutations remain isolated inside that process,
|
||||
so the original store instance passed to
|
||||
[execute()][agentlightning.ExecutionStrategy.execute] is not updated.
|
||||
|
||||
Abort Model (four-step escalation):
|
||||
|
||||
1. Cooperative stop. Every bundle receives a shared
|
||||
[`MultiprocessingEvent`][agentlightning.MultiprocessingEvent] (`stop_evt`).
|
||||
Any failure flips the event so peers can exit cleanly. Ctrl+C on the main
|
||||
process also sets the flag.
|
||||
2. KeyboardInterrupt synthesis. Remaining subprocesses receive ``SIGINT`` to
|
||||
trigger `KeyboardInterrupt` handlers.
|
||||
3. Termination. Stubborn processes are asked to ``terminate()``
|
||||
(`SIGTERM` on POSIX).
|
||||
4. Kill. As a last resort `kill()` is invoked (`SIGKILL` on POSIX).
|
||||
|
||||
This mirrors the semantics implemented in
|
||||
[`SharedMemoryExecutionStrategy`][agentlightning.SharedMemoryExecutionStrategy]
|
||||
but adapts them to multiple processes and the HTTP client/server boundary.
|
||||
"""
|
||||
|
||||
alias: str = "cs"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: Literal["algorithm", "runner", "both"] | None = None,
|
||||
server_host: str | None = None,
|
||||
server_port: int | None = None,
|
||||
n_runners: int = 1,
|
||||
graceful_timeout: float = 10.0,
|
||||
terminate_timeout: float = 10.0,
|
||||
main_process: Literal["algorithm", "runner"] = "algorithm",
|
||||
managed_store: bool | None = None,
|
||||
allowed_exit_codes: Iterable[int] = (0, -15),
|
||||
) -> None:
|
||||
"""Configure the strategy.
|
||||
|
||||
Args:
|
||||
role: Which side(s) to run in this process. When omitted, the
|
||||
`AGL_CURRENT_ROLE` environment variable is used.
|
||||
server_host: Interface the HTTP server binds to when running the
|
||||
algorithm bundle locally. Defaults to `AGL_SERVER_HOST`
|
||||
or `"localhost"` if unset.
|
||||
server_port: Port for the HTTP server in "algorithm"/"both" modes.
|
||||
Defaults to `AGL_SERVER_PORT` or `4747` if unset.
|
||||
n_runners: Number of runner processes to spawn in "runner"/"both".
|
||||
graceful_timeout: How long to wait (seconds) after setting the stop
|
||||
event before escalating to signals.
|
||||
terminate_timeout: How long to wait between escalation steps beyond
|
||||
the cooperative phase (re-used for SIGINT, terminate, and kill).
|
||||
main_process: Which bundle runs on the main process when
|
||||
`role == "both"`. `"runner"` requires `n_runners == 1` and is
|
||||
primarily intended for debugging.
|
||||
managed_store: When `True` (default) the strategy constructs
|
||||
LightningStore client/server wrappers automatically. When
|
||||
`False` the provided `store` is passed directly to the
|
||||
bundles, allowing callers to manage store wrappers manually.
|
||||
allowed_exit_codes: Allowed exit codes for subprocesses.
|
||||
By default, runner can exit gracefully with code 0 or terminated
|
||||
by SIGTERM (-15).
|
||||
"""
|
||||
resolved_role = resolve_str_env_var(LightningEnvVar.AGL_CURRENT_ROLE, override=role, fallback="both")
|
||||
if resolved_role not in ("algorithm", "runner", "both"):
|
||||
raise ValueError("role must be one of 'algorithm', 'runner', or 'both'")
|
||||
self.role: Literal["algorithm", "runner", "both"] = resolved_role
|
||||
self.n_runners = n_runners
|
||||
self.server_host = resolve_str_env_var(
|
||||
LightningEnvVar.AGL_SERVER_HOST, override=server_host, fallback="localhost"
|
||||
)
|
||||
self.server_port = resolve_int_env_var(LightningEnvVar.AGL_SERVER_PORT, override=server_port, fallback=4747)
|
||||
self.graceful_timeout = graceful_timeout
|
||||
self.terminate_timeout = terminate_timeout
|
||||
if main_process not in ("algorithm", "runner"):
|
||||
raise ValueError("main_process must be 'algorithm' or 'runner'")
|
||||
if main_process == "runner":
|
||||
if self.role != "both":
|
||||
raise ValueError("main_process='runner' is only supported when role='both'")
|
||||
if n_runners != 1:
|
||||
raise ValueError("main_process='runner' requires n_runners to be 1")
|
||||
self.main_process = main_process
|
||||
self.managed_store = resolve_bool_env_var(
|
||||
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
|
||||
)
|
||||
self.allowed_exit_codes = tuple(allowed_exit_codes)
|
||||
|
||||
async def _execute_algorithm(
|
||||
self, algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent
|
||||
) -> None:
|
||||
wrapper_store: LightningStore | None = None
|
||||
if self.managed_store:
|
||||
logger.info("Starting LightningStore server on %s:%s", self.server_host, self.server_port)
|
||||
wrapper_store = LightningStoreServer(store, host=self.server_host, port=self.server_port)
|
||||
server_started = False
|
||||
else:
|
||||
wrapper_store = store
|
||||
server_started = False
|
||||
|
||||
try:
|
||||
if self.managed_store and isinstance(wrapper_store, LightningStoreServer):
|
||||
await wrapper_store.start()
|
||||
server_started = True
|
||||
logger.debug("Algorithm bundle starting against endpoint %s", wrapper_store.endpoint)
|
||||
await algorithm(wrapper_store, stop_evt)
|
||||
logger.debug("Algorithm bundle completed successfully")
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Algorithm received CancelledError; signaling stop event")
|
||||
stop_evt.set()
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Algorithm received KeyboardInterrupt; signaling stop event")
|
||||
stop_evt.set()
|
||||
raise
|
||||
except BaseException:
|
||||
logger.exception("Algorithm bundle crashed; signaling stop event")
|
||||
stop_evt.set()
|
||||
raise
|
||||
finally:
|
||||
if self.managed_store and isinstance(wrapper_store, LightningStoreServer) and server_started:
|
||||
try:
|
||||
await wrapper_store.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping LightningStore server")
|
||||
else:
|
||||
logger.debug("LightningStore server shutdown completed")
|
||||
|
||||
async def _execute_runner(
|
||||
self,
|
||||
runner: RunnerBundle,
|
||||
worker_id: int,
|
||||
store: LightningStore,
|
||||
stop_evt: ExecutionEvent,
|
||||
) -> None:
|
||||
if self.managed_store:
|
||||
# If managed, we actually do not use the provided store
|
||||
client_store = LightningStoreClient(f"http://{self.server_host}:{self.server_port}")
|
||||
else:
|
||||
client_store = store
|
||||
try:
|
||||
if self.managed_store:
|
||||
logger.debug("Runner %s connecting to server at %s:%s", worker_id, self.server_host, self.server_port)
|
||||
else:
|
||||
logger.debug("Runner %s executing with provided store", worker_id)
|
||||
await runner(client_store, worker_id, stop_evt)
|
||||
logger.debug("Runner %s completed successfully", worker_id)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Runner %s received CancelledError; signaling stop event", worker_id)
|
||||
stop_evt.set()
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Runner %s received KeyboardInterrupt; signaling stop event", worker_id)
|
||||
stop_evt.set()
|
||||
raise
|
||||
except BaseException:
|
||||
logger.exception("Runner %s crashed; signaling stop event", worker_id)
|
||||
stop_evt.set()
|
||||
raise
|
||||
finally:
|
||||
if self.managed_store and isinstance(client_store, LightningStoreClient):
|
||||
try:
|
||||
await client_store.close()
|
||||
except Exception:
|
||||
logger.exception("Error closing LightningStore client for runner %s", worker_id)
|
||||
else:
|
||||
logger.debug("Runner %s closed LightningStore client", worker_id)
|
||||
|
||||
def _spawn_runners(
|
||||
self,
|
||||
runner: RunnerBundle,
|
||||
store: LightningStore,
|
||||
stop_evt: ExecutionEvent,
|
||||
*,
|
||||
ctx: BaseContext,
|
||||
) -> list[multiprocessing.Process]:
|
||||
"""Used when `role == "runner"` or `role == "both"` and `n_runners > 1`."""
|
||||
processes: list[multiprocessing.Process] = []
|
||||
|
||||
def _runner_sync(runner: RunnerBundle, worker_id: int, store: LightningStore, stop_evt: ExecutionEvent) -> None:
|
||||
# Runners are executed in child processes; each process owns its own
|
||||
# event loop to keep the asyncio scheduler isolated.
|
||||
try:
|
||||
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Runner (asyncio) %s received KeyboardInterrupt; exiting gracefully", worker_id)
|
||||
except BaseException as exc:
|
||||
logger.exception("Runner (asyncio) %s crashed by %s; signaling stop event", worker_id, exc)
|
||||
raise
|
||||
|
||||
for i in range(self.n_runners):
|
||||
process = cast(
|
||||
multiprocessing.Process,
|
||||
ctx.Process(target=_runner_sync, args=(runner, i, store, stop_evt), name=f"runner-{i}"), # type: ignore
|
||||
)
|
||||
process.start()
|
||||
logger.debug("Spawned runner process %s (pid=%s)", process.name, process.pid)
|
||||
processes.append(process)
|
||||
|
||||
return processes
|
||||
|
||||
def _spawn_algorithm_process(
|
||||
self,
|
||||
algorithm: AlgorithmBundle,
|
||||
store: LightningStore,
|
||||
stop_evt: ExecutionEvent,
|
||||
*,
|
||||
ctx: BaseContext,
|
||||
) -> multiprocessing.Process:
|
||||
"""Used when `main_process == "runner"`."""
|
||||
|
||||
def _algorithm_sync(algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent) -> None:
|
||||
try:
|
||||
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Algorithm (asyncio.run) received KeyboardInterrupt; exiting gracefully")
|
||||
except BaseException as exc:
|
||||
logger.exception("Algorithm (asyncio.run) crashed by %s; signaling stop event", exc)
|
||||
raise
|
||||
|
||||
process = cast(
|
||||
multiprocessing.Process,
|
||||
ctx.Process(target=_algorithm_sync, args=(algorithm, store, stop_evt), name="algorithm"), # type: ignore
|
||||
)
|
||||
process.start()
|
||||
logger.debug("Spawned algorithm process %s (pid=%s)", process.name, process.pid)
|
||||
return process
|
||||
|
||||
def _join_until_deadline(
|
||||
self,
|
||||
processes: Iterable[multiprocessing.Process],
|
||||
timeout: float,
|
||||
) -> list[multiprocessing.Process]:
|
||||
"""Join ``processes`` until ``timeout`` elapses, returning those still alive."""
|
||||
deadline = time.monotonic() + timeout
|
||||
still_alive: list[multiprocessing.Process] = []
|
||||
for process in processes:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
process.join(remaining)
|
||||
else:
|
||||
process.join(0)
|
||||
if process.is_alive():
|
||||
still_alive.append(process)
|
||||
return still_alive
|
||||
|
||||
def _signal_processes(
|
||||
self,
|
||||
processes: Iterable[multiprocessing.Process],
|
||||
action: Callable[[multiprocessing.Process], None],
|
||||
) -> None:
|
||||
"""Invoke ``action`` on each process while suppressing individual failures."""
|
||||
for process in processes:
|
||||
try:
|
||||
action(process)
|
||||
except Exception:
|
||||
logger.exception("Error signaling process %s (pid=%s)", process.name, process.pid)
|
||||
|
||||
def _shutdown_processes(
|
||||
self,
|
||||
processes: list[multiprocessing.Process],
|
||||
stop_evt: ExecutionEvent,
|
||||
) -> None:
|
||||
"""4-step escalation shutdown of ``processes``."""
|
||||
if not processes:
|
||||
logger.debug("No subprocesses to shutdown")
|
||||
return
|
||||
|
||||
if not stop_evt.is_set():
|
||||
logger.debug("Sending cooperative stop signal to subprocesses")
|
||||
stop_evt.set()
|
||||
else:
|
||||
logger.debug("Stop event already set; waiting for subprocesses to exit")
|
||||
|
||||
alive = self._join_until_deadline(processes, self.graceful_timeout)
|
||||
if not alive:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Subprocesses still alive after cooperative wait; sending SIGINT to %s",
|
||||
", ".join(p.name or str(p.pid) for p in alive),
|
||||
)
|
||||
# SIGINT is not reliable on Windows, but we do not consider such case yet.
|
||||
self._signal_processes(alive, lambda p: os.kill(cast(int, p.pid), signal.SIGINT))
|
||||
alive = self._join_until_deadline(alive, self.terminate_timeout)
|
||||
if not alive:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Subprocesses still alive after SIGINT wait; sending terminate() to %s",
|
||||
", ".join(p.name or str(p.pid) for p in alive),
|
||||
)
|
||||
self._signal_processes(alive, lambda p: p.terminate())
|
||||
|
||||
alive = self._join_until_deadline(alive, self.terminate_timeout)
|
||||
if not alive:
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Subprocesses still alive after terminate(); sending kill() to %s",
|
||||
", ".join(p.name or str(p.pid) for p in alive),
|
||||
)
|
||||
self._signal_processes(alive, lambda p: p.kill())
|
||||
alive = self._join_until_deadline(alive, self.terminate_timeout)
|
||||
|
||||
if alive:
|
||||
logger.error(
|
||||
"Subprocesses failed to exit even after kill(): %s", ", ".join(p.name or str(p.pid) for p in alive)
|
||||
)
|
||||
|
||||
def _check_process_exitcodes(self, processes: Iterable[multiprocessing.Process]) -> None:
|
||||
"""Raise an error if any managed process exited with a non-zero status."""
|
||||
failed = [p for p in processes if p.exitcode not in self.allowed_exit_codes + (None,)]
|
||||
if failed:
|
||||
formatted = ", ".join(f"{p.name or p.pid} (exitcode={p.exitcode})" for p in failed)
|
||||
raise RuntimeError(f"Subprocesses failed with unexpected exit codes: {formatted}")
|
||||
|
||||
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
|
||||
logger.info(
|
||||
"Starting client-server execution with %d runner(s) [role=%s, main_process=%s]",
|
||||
self.n_runners,
|
||||
self.role,
|
||||
self.main_process,
|
||||
)
|
||||
|
||||
# Re-use the active multiprocessing context so the event and processes
|
||||
# agree on the start method (fork/spawn/forkserver).
|
||||
ctx = multiprocessing.get_context()
|
||||
stop_evt = MultiprocessingEvent(ctx=ctx)
|
||||
# Track spawned processes so we can enforce termination ordering and
|
||||
# surface non-zero exit codes back to the caller.
|
||||
processes: list[multiprocessing.Process] = []
|
||||
|
||||
exception: BaseException | None = None
|
||||
keyboard_interrupt = False
|
||||
|
||||
try:
|
||||
if self.role == "algorithm":
|
||||
logger.info("Running algorithm solely...")
|
||||
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
|
||||
elif self.role == "runner":
|
||||
if self.n_runners == 1:
|
||||
logger.info("Running runner solely...")
|
||||
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
|
||||
else:
|
||||
logger.info("Spawning runner processes...")
|
||||
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
|
||||
# Wait for the processes to finish naturally.
|
||||
for process in processes:
|
||||
process.join()
|
||||
self._check_process_exitcodes(processes)
|
||||
elif self.role == "both":
|
||||
if self.main_process == "algorithm":
|
||||
logger.info("Spawning runner processes...")
|
||||
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
|
||||
try:
|
||||
logger.info("Running algorithm...")
|
||||
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
|
||||
finally:
|
||||
# Always request the runner side to unwind once the
|
||||
# algorithm/server portion finishes (successfully or not).
|
||||
stop_evt.set()
|
||||
else: # main_process == "runner"
|
||||
if self.n_runners > 1:
|
||||
raise ValueError("main_process='runner' requires n_runners to be 1")
|
||||
|
||||
logger.info("Spawning algorithm process...")
|
||||
algorithm_process = self._spawn_algorithm_process(algorithm, store, stop_evt, ctx=ctx)
|
||||
processes = [algorithm_process]
|
||||
|
||||
# Run the lone runner cooperatively in-process so users can
|
||||
# attach a debugger. The algorithm + HTTP server live in
|
||||
# the background process spawned above (the provided
|
||||
# store must therefore be picklable when using spawn).
|
||||
logger.info("Running runner...")
|
||||
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
|
||||
|
||||
# Wait for the algorithm process to finish.
|
||||
algorithm_process.join()
|
||||
else:
|
||||
raise ValueError(f"Unknown role: {self.role}")
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("KeyboardInterrupt received; initiating shutdown")
|
||||
stop_evt.set()
|
||||
keyboard_interrupt = True
|
||||
except BaseException as exc:
|
||||
logger.exception("Unhandled exception in execute method")
|
||||
stop_evt.set()
|
||||
# Preserve the original exception so we can avoid masking it during
|
||||
# the cleanup phase.
|
||||
exception = exc
|
||||
raise
|
||||
finally:
|
||||
logger.info("Shutting down subprocesses")
|
||||
self._shutdown_processes(processes, stop_evt)
|
||||
if processes:
|
||||
try:
|
||||
self._check_process_exitcodes(processes)
|
||||
except RuntimeError as err:
|
||||
if exception is not None or keyboard_interrupt:
|
||||
# We already propagate/handled a different failure, so
|
||||
# emit a warning instead of raising a secondary error.
|
||||
logger.warning("Subprocesses ended abnormally during shutdown: %s", err)
|
||||
else:
|
||||
raise
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import multiprocessing as mp
|
||||
import threading
|
||||
from multiprocessing.context import BaseContext
|
||||
from typing import Optional, Protocol
|
||||
|
||||
|
||||
class ExecutionEvent(Protocol):
|
||||
"""Protocol capturing the cooperative stop contract shared by strategies.
|
||||
|
||||
Implementations mirror the API of ``threading.Event`` and
|
||||
``multiprocessing.Event`` so the rest of the execution layer can remain
|
||||
agnostic to the underlying concurrency primitive.
|
||||
|
||||
Methods:
|
||||
|
||||
set: Signal cancellation. The call must be idempotent.
|
||||
clear: Reset the event to the unsignaled state.
|
||||
is_set: Return ``True`` when cancellation has been requested.
|
||||
wait: Block until the event is signaled or an optional timeout elapses.
|
||||
"""
|
||||
|
||||
def set(self) -> None: ...
|
||||
def clear(self) -> None: ...
|
||||
def is_set(self) -> bool: ...
|
||||
def wait(self, timeout: Optional[float] = None) -> bool: ...
|
||||
|
||||
|
||||
class ThreadingEvent:
|
||||
"""Thread-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
|
||||
|
||||
__slots__ = ("_evt",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._evt = threading.Event()
|
||||
|
||||
def set(self) -> None:
|
||||
self._evt.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._evt.clear()
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._evt.is_set()
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> bool:
|
||||
return self._evt.wait(timeout)
|
||||
|
||||
|
||||
class MultiprocessingEvent:
|
||||
"""Process-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
|
||||
|
||||
__slots__ = ("_evt",)
|
||||
|
||||
def __init__(self, *, ctx: Optional[BaseContext] = None) -> None:
|
||||
self._evt = (ctx or mp).Event()
|
||||
|
||||
def set(self) -> None:
|
||||
self._evt.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._evt.clear()
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._evt.is_set()
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> bool:
|
||||
return self._evt.wait(timeout)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .base import ExecutionStrategy
|
||||
|
||||
|
||||
class InterProcessExecutionStrategy(ExecutionStrategy):
|
||||
"""Placeholder strategy for future inter-process primitives.
|
||||
|
||||
The class exists to reserve the `ipc` alias and make the planned
|
||||
implementation discoverable. Attempting to use it today will raise
|
||||
`NotImplementedError` once the execution contract is finalized.
|
||||
"""
|
||||
|
||||
alias: str = "ipc"
|
||||
|
||||
# TODO: to be implemented
|
||||
@@ -0,0 +1,282 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user