Signals
ctx.wait_for_signal(name) suspends a run until an external system sends a signal by
that name. The worker is freed while it waits, and the run resumes when the signal
lands.
from pyrula.workflows import workflow, Signal
@workflow(name="payment_processor")async def payment_processor(ctx, order_id: str, amount: float) -> str: await ctx.step( "initiate_payment", lambda: initiate_payment( order_id, amount, idempotency_key=f"{ctx.run_id}:initiate_payment", ), )
signal: Signal = await ctx.wait_for_signal("payment_confirmed")
if signal.payload.get("status") == "success": await ctx.step( "fulfill_order", lambda: fulfill_order(order_id, idempotency_key=f"{ctx.run_id}:fulfill_order"), ) return "fulfilled" await ctx.step( "cancel_order", lambda: cancel_order(order_id, idempotency_key=f"{ctx.run_id}:cancel_order"), ) return f"failed: {signal.payload.get('reason')}"The sequence
Section titled “The sequence”- The run hits
wait_for_signal. The engine emitsrun:signal_waiting, sets status tosignal_waiting, and frees the worker. - Something delivers the signal by name with a payload.
- The engine emits
run:signal_receivedand re-queues the run. - On replay the wait point returns the recorded signal and execution continues.
A signal that arrives before the run reaches the wait point is buffered, so there’s no race between the sender and the waiter.
Signal
Section titled “Signal”@dataclass(frozen=True)class Signal: name: str payload: dict[str, Any] sent_at: str # ISO-8601 timestamp set by the senderDelivering a signal over HTTP belongs to the Agents serving layer. The engine only models the wait, the buffer, and the recorded result.