Why a Component Registry
WorkspaceComponentRegistry, NODE_HANDLERS, scan_all, ensure_nodesets_for_graph β what binds a graph's node-type strings to runnable code and owns the env servers, and why batch eval can't skip it
A graph is just JSON, and a node type is just a string β env_habitat__step. Something has to turn that string into runnable code, and start the GPU server sitting behind it. That something is the WorkspaceComponentRegistry. This page argues, from the code, why batch eval cannot skip it: two of its three jobs β bind nameβcode and own the env servers β are exactly what an eval run needs; only the per-process re-scan is incidental tax. The hard fact: delete it and the executor fires nothing β every episode "completes" at step_count = 0. For the wider object map see backend-overview; for why env servers are split shared/replicated see ADR-server-003.
What the GraphExecutor sees β without the registry vs with it. Same one-node graph, two outcomes. The registry is the only thing that fills NODE_HANDLERS for a workspace node type and brings up the server it proxies to. Remove it and the lookup returns None; the node becomes a silent no-op.
1. What it does (and the pattern it is)
The WorkspaceComponentRegistry is the backend's plugin loader + dispatch registry: it discovers domain code in workspace/, binds each node type to a class, and owns the subprocess servers some of those classes need. As a running object it does three things, and that is the whole argument of this page:
- Bind name β code.
scan_all()walksworkspace/, imports the nodesets/nodes it finds, and writes the global tableNODE_HANDLERS(node_type β BaseCanvasNodeclass). That table is the only place the executor looks up a handler. - Own the env servers.
ensure_nodesets_for_graph()brings up the server-mode nodesets a graph needs β attach a stateless shared one, or spawn N copies of a stateful replicated one β andshutdown_all()reaps them. - Keep them fresh. Hot-reload / unload mutate the same table in place (out of scope here; see reload-and-code-freshness).
In design-pattern terms it is the Registry + plugin-loader shape (the -Registry suffix is the tell), held as a field on the per-process ProcessServices service locator. The pattern matters less than the consequence: jobs 1 and 2 are not eval overhead β they are the two things a batch-eval run most needs. The rest of the page proves each, then names the one part that is incidental (Β§5).
The smallest graph that needs it
A one-node graph whose node type carries a __ β e.g. env_habitat__step β already needs the full registry. The __ convention says the prefix (env_habitat) is a nodeset (registry.py Β· ensure_nodesets_for_graph), so running this single node requires the registry to (a) put env_habitat__step in NODE_HANDLERS via scan_all, and (b) have the env_habitat server running. Neither happens by itself. (Pure built-in node types are the exception β see Β§5.)
2. Reason 1 β bind nameβcode is irreducible
Without the table filled, the executor fires nothing β this is mechanical, not a quality issue. The firing engine resolves every node through one lookup (graph_executor.py Β· _fire_node):
async def _fire_node(self, node: NodeInstance, session: Any) -> dict: """Execute a node's handler with its pending inputs and persistent state.""" node_cls = NODE_HANDLERS.get(node.type) if node_cls is None: log.warning("No handler for node type: %s (id=%s)", node.type, node.id) return {}
A node type missing from NODE_HANDLERS doesn't error β it returns an empty dict and the run rolls on. Across a graph of such nodes, the episode "completes" having done nothing. Built-in node types are present in the table at import (builtin_nodes.py:1334, NODE_HANDLERS = {cls.node_type: cls for cls in _BUILTIN_NODES}), but every workspace node type is registered only by scan_all (register_node() mutates the table β builtin_nodes.py:1337). No scan β no workspace handlers β the executor fires nothing.
The eval subprocess proves this is load-bearing in its own comment. Because each process has its own ProcessServices (and therefore its own empty NODE_HANDLERS), the eval runner must re-scan before it can run anything (eval_subprocess_main.py:126-132):
# Subprocess starts with a fresh ProcessServices (in-process singleton not # shared across PIDs). The parent backend ran scan_all in lifespan; # we have to redo it here so workspace nodesets are discovered before # ensure_nodesets_for_graph can load them. Otherwise nodesets fall # into the silent "unknown" bucket and the run "completes" with 0 # steps because no handlers are registered for the graph's node types. registry.scan_all()
That is the left column of the opening diagram, in the code's own words. Whatever you call the loader, batch eval needs some object that fills this table β and that object is the registry.
3. Reason 2 β owning the env servers is batch eval's reason
The part that looks like overhead is the part that exists for eval. Batch eval is the path that runs many episodes in parallel on the GPU; that demands exactly what ensure_nodesets_for_graph(worker_count=N) does β and it dispatches on each nodeset's declared parallelism contract (ADR-server-003, registry.py Β· ensure_nodesets_for_graph):
| Parallelism | What the registry does | Why eval needs it |
|---|---|---|
shared (stateless model) | Attach one copy β register_remote_nodeset(name, url) borrows the parent backend's already-running server (a _RemoteAutoServerShim whose stop() is a no-op). | N workers coalesce through one BatchedInferenceServer instead of loading the model N times β no duplicated VRAM. |
replicated (stateful env) | Spawn N tagged copies β _load_nodeset_as_server(worker_count=N) β BaseServer.start() β Popen(python -m app.server.auto_host), registering N proxies under name#k. | A stateful simulator can't be shared across workers; each parallel worker needs its own env process. |
Both ends are owned by the registry: it brings the servers up at run init and tears them down in shutdown_all() (registry.py:217) β which the eval subprocess calls in its finally precisely because nothing else will reap those env processes. Canvas Play barely touches this layer (single process, worker_count=1); it is batch eval that uses it to the hilt. So the server machinery isn't a tax eval inherited from the canvas β it is the mechanism that lets eval be parallel and VRAM-safe at all.
4. Reason 3 β one loader, both paths, one source of truth
Canvas Play and batch eval run the same graphs against the same node types β so they must share one loader, or behaviour drifts. ensure_nodesets_for_graph is documented as "shared by both canvas execution and eval batch execution" (registry.py:1117); the only difference is where it is driven from:
| Path | Who drives the registry | worker_count |
|---|---|---|
| Canvas Play | main.py Β· lifespan calls scan_all() + initialize_all() once at startup; run.py Β· run_loop calls ensure_nodesets_for_graph() per run. | 1 |
| Batch eval | eval_subprocess_main Β· _build_local_registry_for_run re-scans in the subprocess, then calls ensure_nodesets_for_graph(worker_count=N). | N |
A node behaves identically in Play and in eval because the same discovery, registration, and server-bring-up code ran for both. A bespoke eval-only loader would be a second implementation of "how do I find and start nodeset X" that has to be kept in lock-step with the canvas one forever β the classic source of "works in the canvas, 0 steps in eval" drift. Reusing the registry is what makes that bug-class structurally impossible.
5. Where the argument has caveats (the honest part)
The clean claim is "the registry is essential." Two places that is too strong β and naming them is the point, because they show what is removable (and what isn't).
scan_all() only because ProcessServices is a per-PID singleton β the parent's already-discovered table can't be seen across the process boundary (eval_subprocess_main.py:126-132). That cost belongs to the per-process model, not to "needing a registry": in principle the parent's discovery could be serialized and shipped to the child instead of re-scanned. So if anything here is worth optimizing, it is the re-scan β not the registry itself.
NODE_HANDLERS at import (builtin_nodes.py:1334), and a graph with no __-prefixed nodes triggers no server spawn (ensure_nodesets_for_graph only acts on nodeset prefixes). For such a graph the registry's two main jobs are genuinely idle β it is sized for the heavy parallel-env case and a trivial graph still pays the scan. But batch eval does not run those graphs: a VLN/embodied eval is, by definition, an env-backed graph, which is exactly the case the registry exists for.
Bottom line. "Too complex for batch eval" inverts the dependency: jobs 1 and 2 are what makes a parallel, VRAM-safe, drift-free eval possible, and the executor is mechanically a no-op without job 1. The removable complexity is the cross-process re-scan, not the WorkspaceComponentRegistry. Delete the registry and you must either hard-code node types (lose plugins) or hand-roll a second loader and leak env processes β strictly worse than the object you have.
6. Where to go deeper
| You want⦠| Read |
|---|---|
| The whole backend object map β who owns whom | backend-overview |
| Both run paths class-by-class (canvas / eval) | execution-paths |
| Batch eval internals β workers, parallelism, persistence | batch-eval |
| The server subprocesses themselves β auto_host, manifest, lifecycle | server-mode |
| Why the table re-scans cold per eval (per-process model) | reload-and-code-freshness |
How a node author plugs into NODE_HANDLERS | base-canvas-node Β· nodesets |