Executor Semantics#
This page specifies how the GaP runtime executes a loaded v3 workflow. The
implementation is WorkflowExecutor in
gap/runtime/executor.py; the schema it
consumes is specified in Workflow JSON Schema, and the
practical how-to-run guide is Running Graphs.
The executor is a frontier-based super-step scheduler: each tick it runs every ready node concurrently, folds the outputs back into the scope’s local-outputs dict, then advances the frontier along edges and conditional edges. Subgraph nodes recurse into the same scheduler. Streaming nodes are detached producers consumed by snapshot; Send fan-out spawns dynamic copies of a target node.
Entry#
WorkflowExecutor.execute() (or the gap.execute(...) facade — see
Python API):
resets guard counters and checkpoint results;
runs
validate_workflow— error-severity issues raiseGraphValidationErrorbefore anything executes;initializes the trace (graph topology + a copy of the workflow dir);
starts the observation stream when the connector provides a poll fn;
runs the top-level scope; the terminal node must be
type: end. Itsstatusis recorded asexit_status, recovery runs, the trace flushes, and afailurestatus raisesPipelineError.
Note
WorkflowExecutor.execute() raises on failure; the gap.execute(...)
facade never does — it converts the exception into
ExecutionResult(success=False, error=...). The two entry points also
differ on the checkpoints default: the facade defaults to
checkpoints="warn", the raw executor to "off".
Scope scheduling (super-steps)#
Each scope (the top-level workflow or one subgraph visit) runs the same loop:
frontier := targets of START edges
loop while frontier nonempty:
spawn any streaming nodes in frontier (detached; removed from frontier)
run all remaining ready nodes concurrently # one super-step
fold outputs into local_outputs; mark completed
for each completed node:
follow static edges (dst == END ⇒ record node as scope terminal)
resolve conditional edge (value must be in mapping)
frontier := the newly-discovered targets
All nodes of one super-step run in a
ThreadPoolExecutor(max_node_workers, default 8); a single ready node takes a no-thread hot path. The first exception cancels not-yet-started siblings and re-raises.A node that already completed in this scope is never re-executed — re-entry attempts are silently skipped. Looping is done across subgraphs (each subgraph visit gets a fresh scope), not within one.
Each scope counts super-steps against
node_visit_cap(constructor arg, elseGAP_ITERATION_CAP, default 10000) as a runaway-loop guard. Termination is otherwise a property of the graph’s exit-condition wiring — there is nomax_retriesanywhere in the schema.
Conditional dispatch#
After a node completes, its conditional edge (if any) resolves per the
schema rules. For subgraph nodes
the canonical router_field: "exit" reads the reserved _exit key of the
subgraph node’s result. The resolved value must be a key in the edge’s
mapping; anything else is a runtime PipelineError. A resolved target of
END marks the source as the scope terminal.
Cross-subgraph data#
The executor maintains cross_subgraph_outputs: {sg_name: {output: value}}
— most recent run wins (a loop’s later visit overwrites the earlier
one). At subgraph entry each declared input is bound from the latest
producer, falling back to the facade-supplied initial inputs
(gap.execute(..., inputs={...}), also addressable at top level as
in.<name>). A missing producer raises (the validator’s W8 catches this
statically for name-level wiring).
Warning
Subgraph inputs resolve by name only across subgraphs, and the latest producer wins — two upstream subgraphs declaring an output of the same name silently shadow each other.
Subgraph node execution#
A top-level {"type": "subgraph", "ref": ...} node runs as:
Bind declared inputs (see above) into the reserved
inpseudostate.Run the inner scope with the same super-step loop.
Compute the exit value (exit declaration / on_error) and check it against
success_values ∪ {on_error}.Bind declared outputs by resolving their
$refs against the inner scope’s outputs. Outputs unresolvable on this exit path are dropped with a debug log — downstream consumers simply see no value.Enforce the subgraph’s
validate=Truecheckpoints — normal exits only, never on theon_errorpath (see Checkpoints).Fire the
subgraph_exit_hook(if installed) with aSubgraphExitEvent{sg_name, visit_index, bound_outputs, exit_value, error_path, elapsed_s}. Harnesses use this seam for world snapshots; production runs leave itNone(zero overhead). Hook exceptions are logged, never propagated.The subgraph node’s result is
{**bound_outputs, "_exit": exit_value}— the reserved_exitkey is what the top-level conditional edge dispatches on.
Streaming nodes#
A streaming: true node is spawned into a detached single-thread future the
moment the frontier reaches it; it never blocks downstream readiness and is
removed from the frontier immediately.
Its
StreamSlotis placed inlocal_outputsbefore spawning, so same-super-step consumers resolving$refto its name get the slot;latest()blocks (default 60 s) until the firstctx.publish, then returns the newest value. If nothing is published within the timeout, the consumer’s read raisesPipelineError.The skill convention: loop,
ctx.publish(snapshot)each iteration, checkctx.cancel_token.raise_if_set().On scope exit (success or error) the runtime fires each streaming node’s
CancelToken, grace-waitsGAP_PARALLEL_CANCEL_GRACE_S(default 2.0 s) for cooperative shutdown, then force-cancels stragglers.Streaming spawn is idempotent per scope, and validation guarantees streaming nodes are pure sources (rule S3) whose skill contract matches (rule S4).
Send (dynamic fan-out)#
A router node’s script returns {"route": ...}:
string — static dispatch through the node’s conditional-edges mapping.
list of
{"to": <node>, "inputs": {...}}— one copy of the target node is dispatched per entry, concurrently, with the entry’s inputs merged over the target’s declared inputs. Copies are traced as<target>#<i>, and the results collect into a list stored under the router node’s name, so downstream$refs to the router see the gathered outputs.
End nodes and recovery#
Reaching an end node terminates the top-level scope. Its
recovery tool calls then run
sequentially, best-effort — each failure is logged and the remaining
actions still run. The workflow’s exit status is the end node’s status;
"failure" makes execute() raise after recovery completes. Exiting a
scope without reaching an end node, or terminating on a node that is not
type: end, is also a PipelineError.
Observation stream#
When the connector supplies a poll fn, the executor runs a background
thread polling get_observation() at a configured rate. The rate resolves
in priority order:
workflow
meta.observation_stream_hzthe
GAP_OBSERVATION_STREAM_HZenvironment variablethe constructor’s
observation_hz(default 10 Hz)
The stream is injected as the reserved bound input
in.observation_stream; node bodies receive a per-node handle whose
.latest(timeout) / .latest_with_age(timeout) reads are recorded into the
trace as addressable (node_id, seq) events for deterministic replay
(default timeout 2.0 s). The stream stops in execute()’s finally.
.latest() raises StreamUnavailable until the first successful poll;
after that it is stale-tolerant — it returns the cached last-good
observation even if later polls fail. Poll errors back off from 0.1 s up to
a maximum of 1.0 s, doubling each time.
NodeContext#
Every script/tool node body receives a NodeContext
(gap/runtime/context.py); a script’s
resolved workflow inputs arrive as keyword arguments to its
run(ctx, ...) function, not through the context.
ctx.tool(name, **kwargs)— the sole dispatch call. Applies guard enforcement, invokes through theToolRegistry(connectorrobot.*/sim.*tools, bundle tools,@toolplugins), and records the sub-call into the trace. Dispatch filters kwargs to the tool function’s signature and injectsctxiff the function declares it.ctx.publish(value)— streaming nodes only: publish a snapshot to the node’sStreamSlot. Calling it from a non-streaming node raisesRuntimeError.ctx.cancel_token.raise_if_set()— cooperative cancellation; long loops must call this once per iteration and letTaskCancelledpropagate.ctx.policy_executor— shared websocket-client cache for learned-policy skills.
Guards (call-count safety limits)#
Tools are classified by registry tags: perception, planning,
sim_step. At every ctx.tool dispatch the matching counter is
incremented and checked. Limits resolve in priority order:
per-workflow
gap.tools.guards.set_limits(...)overridesthe
GAP_MAX_PERCEPTION_CALLS/GAP_MAX_PLANNING_CALLS/GAP_MAX_SIM_STEPSenvironment variablesunlimited
Counters reset at the start of every execute().
Warning
GuardLimitExceeded subclasses BaseException, deliberately: a
skill’s bare except Exception cannot swallow a tripped safety guard. See
Safety for the wider safety model.
Skills signal failures by raising gap.errors.PipelineError subclasses
(PerceptionFailed, PlanningFailed, GraspFailed, ValidationFailed,
ToolError, …). The node executors wrap any node-body exception into
NodeExecutionError (preserving the cause); the enclosing subgraph’s
on_error declaration decides whether that becomes a routed failure exit
or a workflow abort.
Tracing#
Tracing is on by default. The trace directory resolves as: constructor
trace_dir argument → GAP_TRACE_DIR environment variable → the workflow
directory (gap run passes outputs/run_<timestamp>/). The on-disk layout
is a stability guarantee consumed by gap viz and gap trace-diff; see
Traces for the full layout and tooling.
What is recorded, and when:
At
execute()start — the graph topology (node and edge registrations) and a snapshot copy of the executedworkflow.jsonplus itsscripts/.As nodes run — per-node resolved inputs, outputs, every
ctx.toolsub-call (request/response), and observation-stream reads, all written incrementally undernode_data/<id>/. Image-, mask-, depth- and pointcloud-shaped numpy values are extracted as PNG/NPY/NPZ assets and summarized in JSON (<ndarray shape=…>), so traces stay browsable without loading gigabytes.At termination —
dag_trace.json(enriched node metadata: timing, status, edges, condition results, the full event log) is flushed when the workflow reaches an end node.
Warning
dag_trace.json is only flushed when an end node is reached. A run that
crashes before reaching one leaves the incrementally written node_data/
on disk but no flushed dag_trace.json.
GAP_TRACE_STREAM_SAMPLE_N (default 1) persists every Nth stream read;
0 disables stream-read recording. See
Environment Variables for all knobs mentioned
on this page.