Skip to main content

Events

Every status change in a running workflow fires an event. There are two layers and you'll use one or the other depending on whether you're integrating from inside a workflow (subscribing to events from THIS workflow's executions) or from outside (every event from every execution).

Two layers

LayerTypeWhere to subscribeScope
EventBus (per-workflow)event_name + ExecutionContextwf.on(event_name, handler) inside @orca.workflowEvents from this workflow's executions only.
System-level IEventSinkRuntimeEvent (Pydantic, JSON-serializable)runtime.register_sink(sink)Every event from every execution. The daemon's /events/stream SSE feeds off this layer.

A _SystemEventForwarder automatically promotes each per-workflow EventBus event into a RuntimeEvent and pushes it to every registered sink. You don't have to wire that yourself.

Event name shape

Events are dot-separated. Two forms:

<entity_type>.<entity_id>.<status>      # specific (most common)
<entity_type>.<status> # generalized (subscribe-friendly)

When the engine emits THREAD.{thread_id}.STARTED it also fans out to any handler subscribed to the generalized THREAD.STARTED. So a sink that wants every thread-start across the system subscribes to the generalized form once; a workflow that wants to react to a specific thread's status subscribes to the 3-part form.

Entity types and their statuses

EntityStatus enumStatuses
ACTIONActionStatusCREATED, RESOLVED, AWAITING_LOCATION_RESERVATION, AWAITING_CO_THREADS, EXECUTING_ACTION, AWAITING_MOVE_RESERVATION, PREPARING_TO_MOVE, PICKING, PLACING, COMPLETED, ERRORED, SKIPPED, ABORTED
METHODMethodStatusCREATED, IN_PROGRESS, COMPLETED, SKIPPED, PARTIAL_COMPLETE
THREADLabwareThreadStatusCREATED, AWAITING_MANUAL_PLACE, RESOLVING_ACTION_LOCATION, AWAITING_ACTION_RESERVATION, ACTION_LOCATION_RESOLVED, AWAITING_MOVE_RESERVATION, AWAITING_MOVE_TARGET_AVAILABILITY, MOVING, AWAITING_CO_THREADS, EXECUTING_ACTION, AWAITING_EVENT, AWAITING_MANUAL_REMOVE, PAUSED, COMPLETED, STOPPING, STOPPED, ABORTED, FAILED (crash-only: the thread's task died on an unhandled error)
WORKFLOWWorkflowStatusCREATED, IN_PROGRESS, COMPLETED, ERRORED
EXECUTIONruntime-levelCOMPLETED, FAILED, ABORTED (terminal only; the runtime emits EXECUTION.{id}.{status} at completion)

Engine-emitted special events

EventWhen
INCIDENT.<incident_id>.<category>A non-action runtime error was recorded as an incident. Summary-only context; full detail lives on the incidents surface.
GROUP.<group_id>.COMPLETEDA multi-group submission's group finishes.
SLOT.<slot_key>.ABANDONEDA receiver slot is closed without contributions.
SLOT.<slot_key>.<status>Other slot lifecycle (overflow handling).
TIP_RACK.EMPTY / TIP_RACK.LOWThe co-labware coordinator detected tip exhaustion.
OPERATOR.INSTRUCTIONAn action called ctx.manual_step(...).
OPERATOR.CONFIRM.<step_id>The operator's confirmation event for a specific manual step.

You're not limited to the engine taxonomy — actions can emit arbitrary event names from inside their body.

The RuntimeEvent shape

System-level sinks (and the SSE stream) deliver RuntimeEvent objects:

class RuntimeEvent(BaseModel):
event_name: str # full 3-part name
execution_id: str
timestamp: float
entity_type: str # parsed from event_name
entity_id: str # parsed from event_name (empty for 2-part)
status: str # parsed from event_name
context: ExecutionContext # type-specific payload

event.context carries the per-event payload. The concrete subclass depends on the entity type:

  • LocationActionExecutionContext for action events
  • MethodExecutionContext for method events
  • ThreadExecutionContext for thread events
  • Generic ExecutionContext for everything else

to_dict() / model_dump(mode="json") give the JSON wire shape — that's what the SSE stream emits.

Subscribing

From inside a workflow

@orca.workflow(name="my_assay")
def my_assay(wf: WorkflowContext):
wf.start(plate_journey)
wf.on("ACTION.COMPLETED", AuditLogger())

wf.on(...) accepts:

  • A callable: def handler(event_name: str, context: ExecutionContext) -> None
  • A SystemBoundEventHandler subclass with a handle(event_name, context) method (useful when the handler needs the bound System for cross-entity lookups)

Handler exceptions are caught and logged to the orca logger — one handler failing does not stop other handlers or the runtime. A persistent handler exception fires a PLUGIN_HANDLER_EXCEPTION or EVENT_HANDLER_EXCEPTION incident.

From outside the workflow (sinks)

For runtime-level integration:

from orca.runtime.interfaces import IEventSink

class JsonlSink:
def __init__(self, path: str):
self._fp = open(path, "a")

def on_event(self, event: RuntimeEvent) -> None:
self._fp.write(event.model_dump_json() + "\n")
self._fp.flush()

runtime.register_sink(JsonlSink("/var/log/orca/events.jsonl"))

IEventSink is a one-method Protocol: on_event(event: RuntimeEvent) -> None. Register multiple sinks — they all receive every event.

Sinks are useful for:

  • Audit trails — JSONL on disk, append-only.
  • Live dashboards — fan out over WebSocket to UI clients.
  • External orchestration — feed events into a downstream system.
  • Persistence + index — the swarm DiskAndIndexEventSink writes JSONL AND maintains a Postgres index for searchability.

Backpressure and ordering

  • on_event is called synchronously from the runtime tick. A sink that blocks the call blocks the runtime. Sinks that need expensive work (DB writes, network) should hand off to a background queue/task.
  • Within one sink, events arrive in the order the runtime emitted them. Across sinks, ordering is not synchronized.
  • The daemon's SSE sink uses per-connection bounded queues (1000 events by default) and drops the oldest event when full — slow subscribers cannot back-pressure the runtime or affect other subscribers.

Daemon REST surfaces

Two endpoints in orca.daemon:

EndpointUse for
GET /events?since=<ts>&execution_id=<id>Polling. Returns every event recorded since the timestamp; optionally filter to one execution.
GET /events/stream?execution_id=<id>Server-Sent Events. Streams live; optional server-side filter.

The SSE stream uses one SseEventSink that persists across load/unload cycles — your subscription stays alive when an operator unloads and loads a different topology.

SSE wire shape:

event: runtime_event
data: {"event_name": "THREAD.tid-2.STARTED", "execution_id": "...", ...}

The data payload is exactly RuntimeEvent.to_dict().

Custom events from actions

Inside an action, emit a custom event:

@orca.action(device=reader, inputs=[plate])
async def read_plate(ctx: ActionContext):
result = await ctx.device().read("absorbance.pro")
await ctx.emit("plate_reading", value="complete", data={"absorbance": result})

Other actions wait for it:

@orca.action(device=evaluator, inputs=[plate])
async def evaluate(ctx: ActionContext):
value, data = await ctx.wait_for("plate_reading")
...

Threads can wait too — ThreadContext.wait_for uses consumed semantics so looping sees each new publish.

Workflows can branch:

yield orca.branch("plate_reading", {
"complete": [evaluate_step],
"failed": [retry_step],
})

Event names with . (qc.result, assay.step1.done) are conventional for namespacing your custom events distinct from the engine taxonomy.

Pitfalls

  • on_event runs synchronously on the runtime tick. Don't block. Persistence sinks should queue or offload.
  • 2-part subscribers see ALL 3-part fans of the same status. Subscribing to ACTION.COMPLETED fires for every action across every thread. If you only want one entity's events, subscribe to the 3-part form.
  • wf.on(...) is per-workflow, not global. It fires only for events emitted by executions of THIS workflow template. For cross-workflow integration use a sink.
  • Handler exceptions are caught and logged — they don't bubble. If a handler is misbehaving, an EVENT_HANDLER_EXCEPTION incident fires. Acknowledging the incident does NOT disable the handler; fix the handler in code.
  • SSE subscriber queues are bounded. A subscriber that can't keep up loses events (oldest dropped). If you need lossless capture, use a sink with backpressure-aware queuing, not the SSE stream.
  • ctx.emit from ActionContext / MethodContext is async. Awaiting it is required — a missed await silently fails to emit.
  • Incidents fire as events. Every recorded incident emits an INCIDENT.{incident_id}.{category} RuntimeEvent (summary fields only; the full typed detail stays queryable on the incidents surface keyed by incident_id). A sink that wants to react to faults can subscribe to the event stream directly; it does not have to poll the incident store.

Where to go next

  • Stores — the IEventSink Protocol and where it sits among the other store interfaces.
  • Context APIemit / wait_for semantics on each context.
  • Recovery — incident model (separate from the event stream).
  • Runtime — wiring sinks at the runtime level.