AgentCanvas / Pages / Developer Guide / Design Docs / Graph / Execution Paths
2026-07-05

Every graph run in AgentCanvas — whether you click ▶ Play on the canvas or submit a batch-eval job — bottoms out in the same two-class engine: LoopRunner.run() driving GraphExecutor.run(). The two paths differ only in how that LoopRunner is created and wrapped — never in how nodes fire. This page is the class-by-class map of both paths and where they converge. For the firing engine itself see graph-executor; for the eval harness internals (workers, parallelism, persistence) see batch-eval; rationale in ADR-eval-001 + ADR-platform-002.

Two entries, one engine. Each lane is a real call chain (labelable file·function); both funnel into the shared LoopRunner → GraphExecutor band. Above the band the two paths differ; from the band down they are identical.

Canvas Play (UI ▶) Batch Eval (job) POST /api/navigate/run run.py·run_loop ExecutionGuard.acquire(canvas) state.py — 409 if eval holds the lock get_loop_runner() → singleton LoopRunner principles=None · no per-worker overrides POST /api/eval/v2/start eval.py·start_eval_v2 · via_subprocess=True JobScheduler.submit → tick admits job_scheduler.py · ΣVRAM≤budget, canvas lock free subprocess: eval_subprocess_main register_remote_nodeset (attach shared singletons) BatchEvalRunner.execute EnvWorkerPool: N × WorkerHandle → fresh LoopRunner per episode (eval_batch.py) LoopRunner.run() → GraphExecutor.run() the shared engine — fires nodes via the ready-queue, identical both paths tool nodes → AutoServerApp subprocess(es) server/auto_server_app.py · BatchedInferenceServer (K-way, eval) Two entries, one engine — canvas Play uses the singleton LoopRunner; eval builds a fresh LoopRunner per episode. Node firing is identical.

1. What it does

There are exactly two ways a graph gets executed:

Both reduce to one engine: a LoopRunner (the control-plane shell) calling GraphExecutor.run() (the firing engine). Everything that differs between the two paths lives above that call — in the wrapper classes that decide:

The name LoopRunner is slightly misleading: it has no loop of its own — the iteration lives entirely inside GraphExecutor.run() (the ready-queue). "Loop" names the agent loop it executes, not a loop in its body.

Smallest of each

The smallest canvas run is one POST carrying the whole graph; the smallest eval is a graph name + an episode count (everything else defaults):

# Canvas Play — fire-and-forget single run (run.py·run_loop)
POST /api/navigate/run
{ "loop_definition": { ...full GraphDefinition JSON... }, "execution_id": "uuid" }
# → { "ok": true, "execution_id": "uuid" }   (runs in a background asyncio task)

# Batch Eval — queued multi-episode run (eval.py·start_eval_v2)
POST /api/eval/v2/start
{ "graph_name": "navgpt_mp3d", "episode_count": 10 }
# → { "run_id": "20260616_...", "status": "queued" }   (JobScheduler admits → subprocess)

2. The shared engine (convergence point)

Both paths converge here. These two classes never change between canvas and eval — only their caller and wrapping do.

ClassWhat it doesWhere
LoopRunner Control-plane shell around one execution. Holds the stop/pause asyncio.Events and passes them into the executor; carries the per-worker routing overrides forward into a freshly-built GraphExecutor; manages the logger + (canvas) graph.json snapshot; applies ExecutionPrinciples (eval mode); doubles as the session object the executor writes step/metrics back into. No loop of its own. loop_runner.py · LoopRunner.run
GraphExecutor The firing engine. Builds the node instances, seeds entry nodes, then drives the ready-queue: a node fires when all its required inputs have arrived; outputs propagate to downstream ports; iterIn/iterOut carry the loop; a StopExecution or budget exhaustion ends the run. Decides what fires when — never the lifecycle. graph_executor.py · GraphExecutor.run

See graph-executor for the ready-queue, iteration, and the after-loop verdict; loop-control-system for the iterIn/iterOut pivots.

3. Canvas Play path (single run, in-process)

One endpoint, the singleton runner, a fire-and-forget task. No subprocess, no worker pool, no eval flags.

  1. run.py · run_loop — the POST /api/navigate/run handler. Validates the graph (validate_graph_connectivity), auto-loads any missing nodesets (WorkspaceComponentRegistry.ensure_nodesets_for_graph), warns on wire-type mismatches, fetches global hooks.
  2. ExecutionGuard.acquire(ExecutionMode.canvas) (state.py) — the single-tenant lock. Canvas and eval are mutually exclusive; if an eval (in-process) holds it, the request returns 409.
  3. get_loop_runner() (loop_runner.py) — returns the process-global singleton LoopRunner. Constructed with defaults: principles=None, empty overrides, no injected logger.
  4. asyncio.create_task(_run_and_release()) — wraps runner.run(graph, step_delay_ms, global_hooks=…) and releases the guard in a finally. The HTTP call returns {ok, execution_id} immediately; the run continues in the background. The task handle is stored on runner._task here (see §6).
  5. Inside runner.run(): with no injected logger, LoopRunner self-creates an ExecutionLogger at outputs/runs/<execution_id>/ and writes graph.json for replay, then builds a fresh GraphExecutor and awaits it.
  6. Controls/run/pause, /run/stop, /run/status, /run/checkpoints, /run/restore/{step} all operate on the same singleton. Pause/stop just flip the runner's Events, which the in-flight executor polls between steps.

4. Batch Eval path (many episodes, subprocess)

A queued, admission-gated, subprocess-isolated run that fans out over workers and builds one LoopRunner per episode. The class roster (top to bottom):

Class / entryWhat it doesWhere
start_eval_v2POST /api/eval/v2/start handler. Builds a job spec (with the GPU-admission declaration) and submits it; via_subprocess defaults True.eval.py · start_eval_v2
JobSchedulerBackend singleton. submit() enqueues; a per-second tick() admits a job when its calibrated per-resource estimate (VRAM + RAM) fits measured free minus reservations and the canvas lock is free, then spawns the run as a subprocess.services/job_scheduler.py · JobScheduler.submit
eval_subprocess_mainSubprocess entry (python -m app.eval_subprocess_main --run-dir …). Reads the spec, attaches the parent backend's shared singletons (register_remote_nodeset) instead of reloading models, runs BatchEvalRunner, writes a _DONE marker on clean exit.eval_subprocess_main.py · main
BatchEvalRunnerOrchestrator. execute() partitions the episode indices into contiguous per-worker chunks (_partition_contiguous) and runs asyncio.gather over N worker_loop coroutines; aggregates metrics (_compute_aggregate); persists.eval_batch.py · BatchEvalRunner.execute
EnvWorkerPool / WorkerHandleAsync context manager holding N worker slots. Each WorkerHandle carries env_panel_overrides + server_url_overrides; acquire_specific(worker_id) pins a chunk to its own subprocess.env_worker_pool.py · EnvWorkerPool
LoopRunner (per episode)A fresh one per episode (Principle: each episode from scratch), built with the handle's overrides + an injected per-episode logger. Then the shared engine takes over (§2).eval_batch.py · _run_one_episode
AutoServerApp / BatchedInferenceServerOut-of-process nodeset host; under worker_count>1 a shared nodeset's server coalesces K callers into one forward pass.server/auto_server_app.py
ExecutionLogger + eval_storagePer-episode node-firing JSONL + run-level summary.json + frozen graph.json.logging/logger.py + eval_storage.py

This page stays at the class-roster level. For the eval internals — worker independence, the replicated vs shared parallelism contract, partial-result harvesting, and the on-disk layout — read batch-eval.

5. Where the paths converge & diverge

The convergence point is LoopRunner.run(). Everything above it differs; everything from it down (the engine, node firing, state, termination) is identical.

Canvas PlayBatch Eval
Entry endpoint/api/navigate/run/api/eval/v2/start
AdmissionExecutionGuard (canvas) — 409 if busyJobScheduler (VRAM budget; default subprocess)
ProcessBackend processSpawned subprocess (default)
Which LoopRunnersingleton (get_loop_runner())fresh per episode (BatchEvalRunner)
ExecutionPrinciplesNone (pausable, nav events on, no source_tag)no_pause, suppress_nav_events, source_tag="eval"
Per-worker overridesnone → proxy nodes hit baked URLenv_panel_overrides / server_url_overrides
Loggerself-created (outputs/runs/…)injected per-episode (eval_runs/…/episodes/…)
Scale1 runN episodes × worker_count workers
From LoopRunner.run() down — identical: build a fresh GraphExecutor, fire nodes via the ready-queue, iterate, terminate.

6. Where it deviates from the clean model

LoopRunner has no loop. The mental model says "a runner runs a loop," but iteration lives entirely in GraphExecutor.run()'s ready-queue. LoopRunner.run() is a single await self._executor.run(...) plus setup. The name refers to the agent loop it executes, not its own control flow.
_task is assigned by the API, not the runner. LoopRunner.stop() awaits self._task, but LoopRunner never sets it — the canvas handler does (run.py · run_loop, runner._task = asyncio.create_task(...)). On a path that calls runner.run() without populating _task (e.g. eval's per-episode runners), stop()'s task-await is a no-op and cancellation rides the stop_event / asyncio.wait_for timeout instead.
Canvas is single-tenant; eval is not (by living elsewhere). The process-global singleton + ExecutionGuard mean you cannot run two canvas graphs at once. Eval sidesteps this by running in its own subprocess (default) — but the legacy in-process eval (via_subprocess=False) shares the very same ExecutionGuard as canvas, so the two contend exactly as the guard intends.
AgentCanvas docs