Execution Paths
LoopRunner, GraphExecutor, ExecutionGuard, JobScheduler, BatchEvalRunner — one engine, two entry paths
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.
1. What it does
There are exactly two ways a graph gets executed:
- Canvas Play — an interactive single run you launch from the UI to watch an agent step live.
- Batch Eval — a headless run of the same graph across many episodes, aggregated into metrics.
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:
- who is allowed to run —
ExecutionGuard(canvas, single-tenant) vsJobScheduler(eval, VRAM-admitted); - where it runs — in the backend process (canvas) vs a spawned subprocess (eval, default);
- which LoopRunner — a process-global singleton (canvas) vs a fresh one per episode (eval);
- how calls are routed — none (canvas) vs per-worker
env_panel_overrides/server_url_overrides(eval); - logging + eval flags — self-created canvas logger vs injected per-episode logger +
ExecutionPrinciples.
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.
| Class | What it does | Where |
|---|---|---|
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.
run.py · run_loop— thePOST /api/navigate/runhandler. Validates the graph (validate_graph_connectivity), auto-loads any missing nodesets (WorkspaceComponentRegistry.ensure_nodesets_for_graph), warns on wire-type mismatches, fetches global hooks.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.get_loop_runner()(loop_runner.py) — returns the process-global singletonLoopRunner. Constructed with defaults:principles=None, empty overrides, no injected logger.asyncio.create_task(_run_and_release())— wrapsrunner.run(graph, step_delay_ms, global_hooks=…)and releases the guard in afinally. The HTTP call returns{ok, execution_id}immediately; the run continues in the background. The task handle is stored onrunner._taskhere (see §6).- Inside
runner.run(): with no injected logger,LoopRunnerself-creates anExecutionLoggeratoutputs/runs/<execution_id>/and writesgraph.jsonfor replay, then builds a freshGraphExecutorandawaits it. - Controls —
/run/pause,/run/stop,/run/status,/run/checkpoints,/run/restore/{step}all operate on the same singleton. Pause/stop just flip the runner'sEvents, 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 / entry | What it does | Where |
|---|---|---|
start_eval_v2 | POST /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 |
JobScheduler | Backend 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_main | Subprocess 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 |
BatchEvalRunner | Orchestrator. 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 / WorkerHandle | Async 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 / BatchedInferenceServer | Out-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_storage | Per-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 Play | Batch Eval | |
|---|---|---|
| Entry endpoint | /api/navigate/run | /api/eval/v2/start |
| Admission | ExecutionGuard (canvas) — 409 if busy | JobScheduler (VRAM budget; default subprocess) |
| Process | Backend process | Spawned subprocess (default) |
Which LoopRunner | singleton (get_loop_runner()) | fresh per episode (BatchEvalRunner) |
ExecutionPrinciples | None (pausable, nav events on, no source_tag) | no_pause, suppress_nav_events, source_tag="eval" |
| Per-worker overrides | none → proxy nodes hit baked URL | env_panel_overrides / server_url_overrides |
| Logger | self-created (outputs/runs/…) | injected per-episode (eval_runs/…/episodes/…) |
| Scale | 1 run | N 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.
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.