Gateway Turn Executor

Place each session's turn on its own worker so a wedged turn is torn down scoped to that session

The turn executor decides where a session’s agent turn runs, so a wedged, runaway, or crashed turn is contained to its own worker instead of taking down every session on the gateway.

Diagram

👤 Session A

📍 place

👤 Session B

📍 place

🖥️ Worker A

🖥️ Worker B
wedges 🔥

✅ result

⚠️ WorkerWedgedError

🧹 teardown

📍 re-place
epoch+1

Quick Start

  1. Default — start an agent, the gateway calls the in-process executor

    Set no executor and the gateway drives every turn through InProcessTurnExecutor, running on the current event loop exactly as today.

    python
    from praisonaiagents import Agent
    from praisonai_bot.gateway import WebSocketGateway
    
    # No executor set → gateway uses InProcessTurnExecutor by default.
    # Every turn runs on the current loop, byte-for-byte compatible.
    agent = Agent(name="Support", instructions="Help users")
    WebSocketGateway(agent=agent).start()
  2. Confirm the active executor from to_dict()

    Read the active executor off the config — the same string gateway doctor reports.

    python
    from praisonaiagents import GatewayConfig
    
    GatewayConfig().to_dict()["executor"]   # → "inprocess"
  3. Plug in an isolated executor via GatewayConfig

    Pass any object satisfying TurnExecutorProtocol to GatewayConfig.executor; the gateway resolves it once at start-up.

    python
    from praisonaiagents import Agent, GatewayConfig
    from praisonaiagents.gateway import InProcessTurnExecutor
    from praisonai_bot.gateway import WebSocketGateway
    
    # Any object satisfying TurnExecutorProtocol works here.
    config = GatewayConfig(executor=InProcessTurnExecutor())
    gateway = WebSocketGateway(agent=Agent(name="Support", instructions="Help"),
                               config=config)
    gateway.start()
    
    config.to_dict()["executor"]   # → "InProcessTurnExecutor"

    Reach for a concrete isolated executor (subprocess / container / remote) when you need blast-radius containment — see Common Patterns.


How It Works

The gateway asks the executor to place a session, runs the turn on that placement, and tears the placement down only when its worker wedges — never the whole process.

Diagram
WorkerTurnExecutorGatewayWorkerTurnExecutorGatewayre-place session with epoch+1alt[worker makes progress][worker wedges]place(session_id)TurnPlacement(worker_id, epoch)execute_turn(placement, turn, cancel_token, limits)run turnresultresultno progressWorkerWedgedErrorteardown(placement, reason)
PieceOwnerRole
TurnExecutorProtocolCore (pure)Async place / execute_turn / teardown contract for where a turn runs
TurnPlacementCore (pure)Frozen handle: which worker owns a session’s turns, and at what epoch
InProcessTurnExecutorCore (pure)Default — runs each turn on the current loop, byte-for-byte compatible
WorkerWedgedErrorCore (pure)Scoped fault signal — tear down this placement, re-place the session

InProcessTurnExecutor is the default and reproduces today’s on-loop behaviour exactly: leaving the executor unset changes nothing and adds no dependency.


How the gateway drives it

WebSocketGateway resolves the executor once at start-up, then routes every turn through place() → execute_turn().

Diagram
sequenceDiagram
    participant User
    participant GW as WebSocketGateway
    participant EX as executor
    participant W as Worker

    User->>GW: message
    GW->>EX: place(session_id)
    EX-->>GW: TurnPlacement
    GW->>EX: execute_turn(placement, turn, cancel_token, limits)
    EX->>W: run turn
    alt worker replies
        W-->>EX: result
        EX-->>GW: result
        GW-->>User: reply (outcome=ok)
    else worker wedges
        EX-->>GW: WorkerWedgedError
        GW->>EX: teardown(placement, reason="wedged")
        GW-->>User: worker wedged; session re-placed (outcome=error)
    end
WhereWhat it does
GatewayConfig.executorSelects the executor. None ⇒ InProcessTurnExecutor.
WebSocketGateway.__init__Resolves self._executor = config.executor or InProcessTurnExecutor().
WebSocketGateway._drive_turnEvery turn goes through executor.place() → executor.execute_turn().
WorkerWedgedError handlerTears down only that session’s worker (scoped, not process-wide).
Session queue outcomeWedged turn reports outcome_status="error", not the default "ok".
GatewayConfig.to_dict()["executor"]"inprocess" by default; the executor class name when explicit.

The Contract

TurnExecutorProtocol is a runtime-checkable protocol with three async methods.

MethodSignatureDoes
placeplace(session_id: str) -> TurnPlacementReturns the placement that owns this session’s turns; may bump epoch when the worker is replaced
execute_turnexecute_turn(placement, turn, *, cancel_token=None, limits=None) -> AnyRuns turn on placement and returns its result; raises WorkerWedgedError if the worker can’t make progress
teardownteardown(placement, *, reason: str) -> NoneReclaims the placement’s worker, scoped to the owning session only

TurnPlacement is a frozen dataclass — mutating any field raises dataclasses.FrozenInstanceError.

FieldTypeDefaultDescription
session_idstr—The resolved session whose turns this placement serves
worker_idstr—Opaque id of the worker that owns the session’s turns; a constant ("inprocess") for the in-process default
epochint0Monotonic generation bumped when the backing worker is replaced — the fencing token; a turn carrying a stale epoch must not run

WorkerWedgedError is a plain Exception subclass. It is scoped: the gateway tears down only the offending placement and re-places its session — never a process-wide os._exit. Ordinary turn errors surface as the turn’s own exception and do not condemn the worker.


Configuration Options

The seam is configured through GatewayConfig.executor (defaults to None, which resolves to InProcessTurnExecutor at runtime). GatewayConfig.to_dict()["executor"] reports the active executor for gateway doctor / observability.


Common Patterns

Three shapes cover almost every use.

python
# 1. Default — no code needed. Leaving the executor unset runs turns on the
#    current loop exactly as today, byte-for-byte backward compatible.
from praisonaiagents.gateway import InProcessTurnExecutor

executor = InProcessTurnExecutor()
python
# 2. A wrapper's isolated executor (sketch — user code, not shipped by core).
from praisonaiagents.gateway import TurnPlacement

class SubprocessTurnExecutor:  # illustrative — your wrapper implements this
    async def place(self, session_id: str) -> TurnPlacement:
        worker_id = await self._spawn(session_id)
        return TurnPlacement(session_id=session_id, worker_id=worker_id, epoch=0)

    async def execute_turn(self, placement, turn, *, cancel_token=None, limits=None):
        return await self._dispatch(placement, cancel_token, limits)

    async def teardown(self, placement, *, reason: str) -> None:
        await self._kill(placement.worker_id)
python
# 3. Handle a wedged worker — tear down, then re-place with a bumped epoch.
from praisonaiagents.gateway import WorkerWedgedError, TurnPlacement

async def run_with_recovery(executor, session_id, turn):
    placement = await executor.place(session_id)
    try:
        return await executor.execute_turn(placement, turn)
    except WorkerWedgedError:
        await executor.teardown(placement, reason="wedged")
        fresh = await executor.place(session_id)  # new worker, epoch+1
        return await executor.execute_turn(fresh, turn)

Best Practices

Fence with epoch, always

epoch is the fencing token. Bump it whenever a session’s worker is replaced, and refuse any turn carrying a stale epoch — that is what stops a reclaimed worker from executing against a session it no longer owns.

Keep teardown scoped to one session

teardown reclaims a single placement’s worker. A worker fault must fail scoped: tear down the offending placement and re-place its session, never the whole gateway. That is the whole point of the seam versus a process-wide os._exit.

Reach for isolation only when you need blast-radius containment

The in-process default is zero-cost and dependency-free but offers no isolation — a turn that wedges the loop still affects the process. Switch to an isolated executor (subprocess / container / remote) when one bad session must not degrade the rest.

Never cross a boundary with a live callable

An isolated worker owns the session via place() and rebuilds the turn from serialisable inputs on its own side. Do not try to send the live turn callable across a process boundary — treat it as the gateway-side await point for the worker’s result.

Leave executor=None unless you need isolation

The default GatewayConfig(executor=None) resolves to InProcessTurnExecutor — today’s on-loop behaviour, no dependency, no cost. Only set executor= when you need blast-radius containment (subprocess / container / remote) or per-session limits. Verify what actually resolved with config.to_dict()["executor"] — that same string is what gateway doctor prints.


Related