85742ab165
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
444 lines
20 KiB
Python
444 lines
20 KiB
Python
# 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
|