AgentCanvas / Pages / Developer Guide / Design Docs / Graph / Canvas Play
2026-06-16

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.

Browser — Canvas UI ▶ Play ⏸ ⏹ ⟲ Restore holds one WebSocket open Backend process — in-process · single-tenant (ExecutionGuard) POST /api/navigate/run run.py · run_loop ExecutionGuard.acquire(canvas) 409 if busy · holder = "canvas" get_loop_runner() → singleton LoopRunner principles=None · no overrides asyncio task: _run_and_release (fire-and-forget) GraphExecutor.run() — ready queue step_delay_ms throttle (200 ms) pause_event.wait() / stop_event each step checkpoint per root step (in-memory, ≤100) ▶ Play live WS ▸ nav_status · nav_step · exec_log · viewer_data pause / stop / restore AutoServerApp · registry-owned HTTP /call — no per-worker tagging same servers canvas & eval share tool → HTTP Node firing is identical to a batch-eval episode — only the wrapping (singleton, in-process, live, pausable) differs.

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:

  1. In-process. Unlike eval, there is no subprocess and no scheduler — the run executes as an asyncio task inside the backend itself (§ 2–§ 3). That's what makes it cheap to start and live to watch, and why it is single-tenant.
  2. Single-tenant, one singleton. ExecutionGuard permits exactly one canvas (or eval) run at a time, and the run is driven by the one process-global LoopRunner from get_loop_runner(). Starting a new Play stops the current one (§ 2).
  3. 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.

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):

  1. Acquire the guard. ExecutionGuard.acquire(ExecutionMode.canvas, "canvas") → 409 on failure. Then get_loop_runner(), and if the singleton is mid-run, await runner.stop() it.
  2. Parse + validate. GraphDefinition.from_dict(req.loop_definition) + validate_graph_connectivity; on a bad graph it releases the guard and returns 400.
  3. Auto-load nodesets. registry.ensure_nodesets_for_graph(graph) (worker_count=1) makes sure every node type is in NODE_HANDLERS — idempotent, covers a cold start or a --reload-wiped registry. A load failure releases the guard and returns 500.
  4. Wire-type check + hooks. validate_edge_wire_types (warn-only, ADR-027 staged) now that proxy classes are registered; registry.get_global_hooks().
  5. 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):

The executor (graph_executor.py · GraphExecutor.run) drives the ready-queue exactly as in eval, with two canvas-visible behaviours that eval turns off:

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:

EventEmitted byWhen
nav_statusLoopRunner / executorrunning / paused / idle / complete transitions
nav_stepGraphExecutor._broadcast_steponce per root step — the consolidated per-step view
exec_logGraphExecutor._fire_nodeper node fire — the Report-tab firing log
viewer_dataviewer 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):

EndpointEffect
POST /run/pauseIf running, runner.pause() → clears _pause_event; the executor blocks at the next step's pause.wait().
POST /run/stoprunner.stop() (sets stop event, awaits the task with a 5 s timeout, then cancels) and releases the guard.
GET /run/statusrunner.get_status(){status, step, metrics, execution_id}.
GET /run/checkpointsThe 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.

AgentCanvas docs