degenbot.runner.bot_runner ========================== .. py:module:: degenbot.runner.bot_runner .. autoapi-nested-parse:: Settlement-arbitrage runtime driver facade (``BotRunner``). ``BotRunner`` is the Python-companion cockpit over the Rust-owned engine: it owns the config and the lifecycle, while the coordination state itself (the three actors ``bot``/``engine_registry``/``async_w3`` + the ``Dispatcher`` + the block clock) is owned by the ONE ``_SessionState`` built in ``start()``. It is the one place that enforces the phase ordering the engine's state machine requires. start(): subscribe -> stream snapshots -> backfill -> verify config (``EngineRegistry.start``, stops at Backfilled, pre-resume) run(): the run ritual (``_run_ritual``) drives consumer-attach -> watch-attach -> ``resume()`` -> registration -> main loop The driver is ``stays-python`` (asyncio loop, SIGINT, deployment policy): it controls the Rust engine but owns no pool state (ADR-003: ``Bot`` is the single state owner; ADR-006: ``Bot`` is the per-chain orchestrator). **Sequencing supersedure (ADR-050, 2026-09-14):** the start/run sequencing described here is Rust-owned in ``EngineDriver``; only asyncio/SIGINT/ deployment policy stays ``stays-python`` — see ``docs/architecture/rust-settlement-bot-parity.md``. It delegates path registration to :mod:`~degenbot.runner.build_paths` and the main loop to :mod:`~degenbot.runner.consume`. Testability seams (mirrors ``EngineRegistry``'s ``engine=`` seam): ``bot``, ``engine_registry``, ``async_w3``, ``snapshots``, ``path_builder``, and ``consumer`` are injectable. Module Contents --------------- .. py:exception:: ActivationGateRefused(refusal: ValueError) Bases: :py:obj:`RuntimeError` Strategy readiness (the activation gate) refused a live session. The gate refuses an activated facet that fails readiness, so a live boot either aborts here or degrades a broadcast to the public mempool; the gate's ValueError text becomes the message so the operator keeps the gate's own diagnostic unchanged. .. py:exception:: PhaseError Bases: :py:obj:`RuntimeError` Cockpit phase violation: a lifecycle method ran in the wrong phase. The session phase machine is ``New -> Started -> Running -> Closed``: ``start()`` builds once from ``New`` and is an idempotent no-op on ``Started`` re-entry (Running/Closed re-entry raises); ``run()`` requires ``Started``; ``enqueue_path`` / ``trigger_discovery`` require ``Running``; ``shutdown()`` stays deliberately any-phase and idempotent (the SIGINT teardown ordering depends on it). The transition table itself is the Rust host's ``SessionPhase`` (``strategy_host.rs``), read through ``degenbot._ffi.session_phase_next``; this exception is the Python surface of its refusals. .. py:class:: InjectedActors Test/DI actor overrides for :class:`BotRunner` (``None`` = build from cfg). .. py:attribute:: bot :type: degenbot.Bot | None :value: None .. py:attribute:: engine_registry :type: degenbot.arbitrage.engine_registry.EngineRegistry | None :value: None .. py:attribute:: async_w3 :type: degenbot.provider.AsyncAlloyProvider | None :value: None .. py:attribute:: snapshots :type: tuple[Any, Any, Any, Any] | None :value: None .. py:attribute:: path_builder :type: Any :value: None .. py:attribute:: consumer :type: Any :value: None .. py:attribute:: pipeline_factory :type: collections.abc.Callable[[_SessionState], Any] | None :value: None .. py:attribute:: relay_posture :type: degenbot.runner._relay_posture.RelayPosture | None :value: None .. py:attribute:: settlement_arm :type: bool | None :value: None .. py:attribute:: readiness :type: collections.abc.Callable[[], degenbot.strategy.StrategyReadinessView] | None :value: None .. py:attribute:: settlement_endpoints :type: collections.abc.Callable[[], list[str]] | None :value: None .. py:attribute:: stop_engine :type: collections.abc.Callable[[], None] | None :value: None .. py:attribute:: scheduler :type: collections.abc.Callable[[collections.abc.Coroutine[Any, Any, None]], asyncio.Task[Any]] .. py:class:: BotRunner(cfg: degenbot.runner.config.ArbitrageConfig, *, actors: InjectedActors | None = None, install_sigint: bool = True) Orchestrator that collapses the settlement-arbitrage startup ritual behind one facade. Owns the config and the lifecycle; the coordination state itself (the three actors ``bot``/``engine_registry``/``async_w3``, the ``Dispatcher``, the block clock, the sim context, the pipelines) lives on ONE :class:`_SessionState` built in ``start()`` — the runner's same-named attributes are a facade over that owner, not mirrors. The runner is the ONE place that enforces the phase ordering the engine's state machine requires: start(): subscribe → stream snapshots → backfill → verify config (``EngineRegistry.start``, stops at Backfilled, pre-resume) run(): the run ritual (``_run_ritual.RunRitual``) owns the startup ordering — attach consumer → watch → ``resume()`` → registration → main loop — as a state machine; the cross-task fail-fast channel surfaces a fatal registration error. Usage (production):: cfg = ArbitrageConfig.build(live=not dry_run, permutation=args.permutation) async with BotRunner(cfg) as session: await session.run() In production ``run()`` schedules discovery+registration as a background task (through the injected scheduler) and enters the main loop immediately; the state-trim runs on registration completion (inside the scheduled hand-off), not on the main-loop entry path. A fatal verification error still crashes loudly through the cross-task channel. The hot loop keeps only ``engine_registry`` + ``async_w3`` + dispatcher once trimmed — the Python pool/token caches are scaffolding once the Rust engine owns canonical state. Testability seams (mirrors ``EngineRegistry``'s ``engine=`` seam): ``bot``, ``engine_registry``, ``async_w3``, ``snapshots``, ``path_builder``, ``consumer``, and the registration ``scheduler`` are injectable. When injected, ``start()``/``run()`` orchestrate the fakes and the phase ordering is verifiable offline; when ``None`` (production), the actors are built from ``cfg`` and the real module functions are called. .. py:attribute:: cfg .. py:property:: bot :type: degenbot.Bot | None The session's Python-companion bot (``None`` once the trim dropped it). Before ``start()``: the injected seam (a write is an injection). .. py:property:: engine_registry :type: degenbot.arbitrage.engine_registry.EngineRegistry | None the injected seam. :type: The session's engine registry. Before ``start()`` .. py:property:: async_w3 :type: degenbot.provider.AsyncAlloyProvider | None the injected seam. :type: The session's dispatch-path provider. Before ``start()`` .. py:property:: dispatcher :type: degenbot.dispatch.Dispatcher | None ``None`` (no session yet). :type: The session's dispatcher. Before ``start()`` .. py:property:: session :type: _SessionState | None The session owner (``None`` only before ``start()``). .. py:property:: session_watch :type: degenbot.runner._session_watch.SessionWatch The session watch the ritual attaches the run's members to. .. py:property:: consumer :type: Any The injected consumer (``None`` = the production block loop). .. py:property:: readiness :type: degenbot.strategy.StrategyReadinessView | None The readiness view resolved once at the ``start()`` boundary. .. py:property:: settlement_active :type: bool The resolved settlement-arm disposition (read by the ritual). .. py:property:: path_builder :type: Any The injected path builder (``None`` = the real ``build_paths``). .. py:property:: scheduler :type: collections.abc.Callable[[collections.abc.Coroutine[Any, Any, None]], asyncio.Task[Any]] The registration hand-off scheduler (data; production ``create_task``). .. py:property:: trim :type: collections.abc.Callable[..., None] The python-state trim (the registration hand-off's completion duty). .. py:property:: pump_finished_watchdog :type: collections.abc.Callable[..., collections.abc.Coroutine[Any, Any, None]] The pump-finished watchdog factory (the watch's always-on member). .. py:method:: start() -> BotRunner :async: Build the actors, fetch block state, load snapshots, run ``engine_registry.start()``. Stops at ``Backfilled`` — BEFORE ``resume()``. Zero result batches emit during this window (the pump isn't running), so ``run()`` can attach the consumer in the gap before ``resume()`` without a stale-backlog window. Idempotent via the phase alone: re-entry once Started is a no-op; Running/Closed re-entry raises :class:`PhaseError`. :returns: The started runner, stopped at ``Backfilled``. :raises RuntimeError: If the latest-block fetch fails at session start. .. py:method:: run() -> degenbot.runner._session_watch.SessionEndVerdict :async: Run the cockpit main loop until the consumer task ends. Requires the ``Started`` phase — the session phase machine (:class:`PhaseError`, delegating to the Rust host's ``SessionPhase`` table) owns the lifecycle gate. The startup ordering itself is the run ritual's (:mod:`~degenbot.runner._run_ritual`): this method is the thin phase-gated drive over that machine. :returns: The session's end verdict (the session watch's ranking over how the run ended). Consuming it is optional — callers that ignore it behave exactly as before this return existed. .. py:method:: enqueue_path(path_steps: Any, directions: list[bool] | None = None) -> None :async: Add ONE specific path at any time (the operator surface). Delegates to the session's live :class:`PathRegistrationPipeline` (created by the run ritual); ``path_steps`` + optional ``directions`` are the same shapes as :meth:`PathRegistrationPipeline.enqueue_path`. The path is built via the retained ``ConstructionContext`` (Rust ``PoolBuilder``), registered + verified, released to ``Live``, and registered — without disturbing the pump's update/solve/dispatch. :raises RuntimeError: if no live pipeline exists (injected fake builders have no construction surface, or ``run()`` has not run). .. py:method:: trigger_discovery(*, bound: int | None = None) -> int :async: Trigger a bounded one-shot discovery sweep (on-demand trigger). Delegates to the session's live pipeline. :returns: The number of paths processed by the sweep. :raises RuntimeError: if no live pipeline exists (injected fake builders, or ``run()`` has not run). .. py:method:: shutdown() -> None :async: Signal the Rust core to stop the pump (best-effort). Safe to call at any point in the lifecycle — before ``start()`` finished (``engine_registry`` may be ``None``), after ``run()`` exited, or from a ``SIGINT``/``KeyboardInterrupt`` handler. Mirrors the Rust ``stop()`` contract: idempotent, sets the shutdown flag + aborts the pump task so the WS stream's ``combined.next().await`` unblocks immediately (60s cold-shutdown otherwise). Any exception is swallowed and logged so a partial-startup teardown can't mask the original in-flight exception. This is the one place that closes the Rust core's pump — the ``KeyboardInterrupt``-exits-slowly bug was the pump task (spawned on the shared tokio runtime, decoupled from the asyncio loop) blocking on a silent WS subscription, which ``asyncio.run``'s teardown did not reach until the OS closed the socket.