Canvas Play
POST /api/navigate/run · ExecutionGuard · the singleton LoopRunner · live WS (nav_step / viewer_data) · pause / checkpoint / restore
Canvas Play is what happens when you click ▶ Play on the canvas: one interactive run, watched live. It runs in the backend process itself — no subprocess — and is single-tenant: ExecutionGuard allows exactly one canvas run at a time, and the run is driven by a process-global singleton LoopRunner. The HTTP call is fire-and-forget (it returns the moment the run starts), with every step streamed over WebSocket and pause / stop / checkpoint / restore hitting that same singleton. Its hard promise: it fires nodes through the very same GraphExecutor as batch eval, so what you watch on the canvas is bit-for-bit what an eval episode runs. For the eval path see batch-eval; for the two paths side by side see execution-paths; for the firing engine itself see graph-executor.
One Play click, all in the backend process. The request acquires the canvas lock, grabs the singleton runner, and launches a fire-and-forget task; the engine drives the ready-queue with a throttle and live WS broadcasts back to the browser; the interactive controls (pause / stop / restore) re-enter the same singleton.
1. What it does
Canvas Play is the interactive sibling of batch eval. You draw a graph, click ▶ Play, and watch the agent step through one run live — frames, log lines, and node outputs streaming into the canvas as they happen. Three properties define the path, and the rest of the page traces each through code:
- In-process. Unlike eval, there is no subprocess and no scheduler — the run executes as an
asynciotask inside the backend itself (§ 2–§ 3). That's what makes it cheap to start and live to watch, and why it is single-tenant. - Single-tenant, one singleton.
ExecutionGuardpermits exactly one canvas (or eval) run at a time, and the run is driven by the one process-globalLoopRunnerfromget_loop_runner(). Starting a new Play stops the current one (§ 2). - Same engine as eval. Below
LoopRunner.run()the firing is identical to a batch-eval episode — the only differences are the wrapping (singleton vs fresh-per-episode, in-process vs subprocess, live vs suppressed). This is the page's hard promise (§ 4).
The smallest run is a single POST carrying the whole graph; it returns immediately, and the run continues in the background:
POST /api/navigate/run { "loop_definition": { ...full GraphDefinition JSON... }, "execution_id": "uuid", "step_delay_ms": 200 } # → { "ok": true, "execution_id": "uuid" } returns NOW; the run continues in the background
Note the contrast with eval's contract: canvas posts the whole graph inline (loop_definition) and gets back ok the instant the task is scheduled; eval posts a graph name and gets back queued while a scheduler decides when to run it.
2. One process, single-tenant
There is exactly one canvas run, driven by one runner, gated by one lock — all process-local.
ExecutionGuard(state.py · ExecutionGuard) is a class-level lock with three modes (idle/canvas/eval).run_loopacquires it incanvasmode with the literal holder string"canvas"(eval uses a run_id); if it's already held — by another canvas run or an in-process eval — the request returns 409. Canvas and eval are mutually exclusive by construction.- The
LoopRunneris a process-global singleton (loop_runner.py · get_loop_runner()). Every canvas endpoint — run, pause, stop, status, checkpoints, restore — operates on the same object. There is no per-runLoopRunneron this path (that's eval). - A new Play preempts the running one.
run_loopchecksrunner._status in ("running", "paused")andawait runner.stop()s it before starting the new run (run.py · run_loop) — the single slot is taken over, not refused (the 409 is only for a held guard, e.g. an eval holds it).
This is the whole reason eval exists as a separate path: you cannot run two graphs at once on the canvas, and a long headless sweep would monopolize the one slot. The default subprocess eval sidesteps the guard entirely; only the legacy in-process eval contends for it. See execution-paths for the side-by-side.
3. The request path — run_loop
The handler does five things, then hands off to a background task. run.py · run_loop (POST /api/navigate/run, router mounted at /api/navigate):
- Acquire the guard.
ExecutionGuard.acquire(ExecutionMode.canvas, "canvas")→ 409 on failure. Thenget_loop_runner(), and if the singleton is mid-run,await runner.stop()it. - Parse + validate.
GraphDefinition.from_dict(req.loop_definition)+validate_graph_connectivity; on a bad graph it releases the guard and returns 400. - Auto-load nodesets.
registry.ensure_nodesets_for_graph(graph)(worker_count=1) makes sure every node type is inNODE_HANDLERS— idempotent, covers a cold start or a--reload-wiped registry. A load failure releases the guard and returns 500. - Wire-type check + hooks.
validate_edge_wire_types(warn-only, ADR-027 staged) now that proxy classes are registered;registry.get_global_hooks(). - Fire-and-forget. Set
runner._execution_id, clear the stop event, and launch the task — the HTTP call returns immediately while the run proceeds in the background:
async def _run_and_release(): try: await runner.run(graph, req.step_delay_ms, global_hooks=global_hooks) finally: ExecutionGuard.release("canvas") # released when run() returns runner._execution_id = req.execution_id runner._task = asyncio.create_task(_run_and_release()) # fire-and-forget return {"ok": True, "execution_id": req.execution_id}
The guard is released in the task's finally — i.e. when run() returns — or explicitly by POST /run/stop. The one case where neither fires is a run paused while the browser closes; a liveness backstop handles it (§ 7).
4. Inside the run — LoopRunner → GraphExecutor
The singleton LoopRunner is a thin control shell; the run is the executor. With principles=None (canvas mode), LoopRunner.run (loop_runner.py · LoopRunner.run):
- Self-creates a logger. Eval injects a per-episode
ExecutionLogger; canvas has none, so the runner builds one atoutputs/runs/<execution_id>/and writesgraph.jsonfor replay. - Keeps the pause event live. Canvas does not set
no_pause, so the runner's own_pause_eventis used — the executor blocks on it between steps when you hit Pause. - Builds a fresh
GraphExecutorcarrying empty overrides (no per-worker routing) andawaitsexecutor.run(graph, session=self, step_delay_ms, stop_event, pause_event, global_hooks).
The executor (graph_executor.py · GraphExecutor.run) drives the ready-queue exactly as in eval, with two canvas-visible behaviours that eval turns off:
- Step throttle. Between root-scope steps the executor sleeps
step_delay_ms(graph_executor.py:if step_delay_ms > 0 and _is_root_scope: await asyncio.sleep(step_delay_ms / 1000)). Canvas defaults to 200 ms so a human can watch; eval passes 0. - Checkpoints. On each root-scope
iterOutboundary the executor snapshots every state container into an in-memory dict (graph_executor.py:self._checkpoints[self.step_counter] = {cid: c.checkpoint() …}), capped atmax_checkpoints = 100(oldest evicted). This is what backsrestore(§ 6).
Pause and stop are polled at the top of each step: await pause.wait() blocks a paused run, and a set stop_event breaks the loop (graph_executor.py · GraphExecutor.run). Node firing, iteration, termination, and the after-loop verdict are identical to eval — see graph-executor.
5. Live observability
Because the run is in-process, its WS events reach the browser directly — the opposite of eval. state.broadcast fans every event to the backend's connected WebSocket clients (state.py · broadcast), and the canvas holds one open. With principles=None, none of the nav events are suppressed:
| Event | Emitted by | When |
|---|---|---|
nav_status | LoopRunner / executor | running / paused / idle / complete transitions |
nav_step | GraphExecutor._broadcast_step | once per root step — the consolidated per-step view |
exec_log | GraphExecutor._fire_node | per node fire — the Report-tab firing log |
viewer_data | viewer sink nodes (builtin_nodes.py) | per fire — each viewer node emits its own payload (frames, text) |
Eval flips the same switch the other way: ExecutionPrinciples(suppress_nav_events=True) gates nav_status / nav_step / exec_log off, and the eval subprocess has no WS clients anyway (batch-eval § 6.3). On canvas all four stream live. GET /run/status additionally exposes a pull snapshot ({status, step, metrics, execution_id}) for clients that reconnect mid-run.
6. Pause / stop / checkpoint / restore
The interactive controls are four more endpoints on the same singleton (all under /api/navigate, run.py):
| Endpoint | Effect |
|---|---|
POST /run/pause | If running, runner.pause() → clears _pause_event; the executor blocks at the next step's pause.wait(). |
POST /run/stop | runner.stop() (sets stop event, awaits the task with a 5 s timeout, then cancels) and releases the guard. |
GET /run/status | runner.get_status() — {status, step, metrics, execution_id}. |
GET /run/checkpoints | The step numbers currently snapshotted in memory. |
POST /run/restore/{step} | Rewinds container state to a checkpoint. Requires the runner be paused or done (400 otherwise); 404 if no checkpoint at that step. |
Resume is implicit: POST /run after a pause is handled by the runner's pause/resume events; restore only mutates the executor's container snapshots, it does not itself resume. Checkpoints live only in the running executor's memory (§ 4) — they are a within-session rewind, not a persisted history.
7. Where the code deviates from the mental model
LoopRunner has no loop. The name says "runner of a loop," but the iteration lives entirely in GraphExecutor.run()'s ready-queue; LoopRunner.run() is setup plus a single await self._executor.run(...). "Loop" names the agent loop it executes, not its own control flow.
A new Play silently stops the running one. run_loop only returns 409 when the guard is held by something else (e.g. an in-process eval). A second canvas Play while one is running does not 409 — it await runner.stop()s the in-flight run and takes the slot. There is one canvas slot, and the latest Play wins it.
_task is assigned by the API, not the runner. LoopRunner.stop() awaits self._task, but the runner never sets it — run.py · run_loop does (runner._task = asyncio.create_task(...)). Eval's per-episode runners never populate _task, so there stop()'s task-await is a no-op and cancellation rides the stop event / wait_for timeout instead.
A paused run that loses its browser leaks the guard — until a backstop reaps it. The guard is released only on /run/stop or when run() returns; a run paused while the frontend closes does neither, so JobScheduler would starve every eval job forever. The WS-liveness backstop (state.py · canvas_guard_orphan_decision, grace CANVAS_ORPHAN_GRACE_SEC = 30 s) reaps a canvas guard held with zero connected clients past the grace window.
Checkpoints are in-memory and capped. self._checkpoints holds at most 100 root steps and is discarded when the executor object is — restore is a live-session rewind, not a persisted trajectory. For after-the-fact replay use the outputs/runs/<execution_id>/ log + frozen graph.json the runner self-created, not the checkpoint dict.