# 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