AgentCanvas / Pages / Developer Guide / Design Docs / Operations / Reload & Code Freshness
2026-06-15 13:34

The promise behind running agent-edited code: a run uses the code you meant — not a stale copy, not a half-updated mix. This page is the developer-facing internals of how that promise is kept (grounded in file·function), and the few places where today's code does not yet match the intended model. For how a run is launched and observed see Batch Eval System; for the parallelism contract that decides which servers are shared see ADR-server-003.

DISK — source of truth workspace/ (frozen) + active overlay (optional) re-exec from disk under a fresh synthetic name — no importlib.reload, no module cache scan every boot scan once @ startup import once @ spawn EVAL RUN cold subprocess (Popen) scan_all() at boot, re-reads disk ✓ FRESH every run LIVE BACKEND canvas Play · in-process scanned once at startup, then held in registry ⟳ manual reload / watcher SHARED auto_host GPU singletons · long-lived imported once at spawn, parent-owned ⚠ STICKY — drifts invariant 3 — immutable snapshot manual-only (intended policy) deviation ① (§4) — until reload

1. The mental model

Four invariants — everything else follows. The first three describe how code reaches a run; the fourth is the intended reload policy the code is still converging on (§4 marks where it doesn't yet hold).

The smallest case. Edit one nodeset file, then watch two consumers diverge:

# edited on disk: workspace/nodesets/method/foo/foo.py

POST /api/eval/v2/start { "graph_name": "foo_graph", "episode_count": 1 }
#   → runs the NEW foo.py — the eval is a cold subprocess that re-scans disk at boot

# the live canvas still holds the OLD foo.py until you ask:
POST /api/components/reload
#   → shutdown_all → scan_all → initialize_all   (full re-discovery)

Two consumers, two freshness rules: the cold eval is fresh for free; the live backend waits for an explicit reload. The rest of the page is why, and the one consumer (shared servers) where even that isn't enough.

2. How the code does it

Re-import primitive. The registry never calls importlib.reload and never trusts the module cache. registry.py · _import_module pops any stale sys.modules entry and exec_modules the file from disk under a fresh synthetic name carrying a scan-cycle counter (mod_name = f"_ws_{subdir}_{stem}_{scan_cycle}"), so the new class object replaces the old. This is what makes invariant 2 true.

Eval = a pinned snapshot. An eval run is a fresh interpreter — job_scheduler.py · _spawn does subprocess.Popen([sys.executable, "-m", "app.eval_subprocess_main", …]), not a fork — and eval_subprocess_main.py · _build_local_registry_for_run re-runs registry.scan_all() from disk at startup; the graph is re-read fresh per submit (eval.py · _load_graph_by_name). All worker_count=K workers are spawned from the one resolved source_file (registry.py · _load_nodeset_as_server), each gated on /health before episodes start (base_server.py · _wait_for_health). Once up, a worker never re-reads disk — so a mid-run edit cannot reach it; you get the new code on the next run. This realizes invariant 3.

Live backend = manual reload. The long-lived backend scans once at startup (main.py · lifespan) and then holds that code for the canvas and for shared servers. To refresh it, POST /api/components/reload runs shutdown_all → scan_all → initialize_all (api/platform/components.py · reload_components) — a complete re-discovery (all parallelisms, policies, hooks, and deletions, because scan_all clears first).

3. Active-workspace overlay

An optional overlay copy of the workspace takes precedence over the committed frozen one, so a search loop (the architect pipeline) can try edited code without dirtying frozen for sibling sessions. Two scopes: process-wide (ACTIVE_WORKSPACE_DIR at backend startup — affects everything, including the live canvas) or per-eval-request (--workspace=… — reaches only that one eval subprocess).

Resolution is overlay-first everywhere. registry.py · scan_all runs two passes — frozen, then overlay, same name wins; graph-by-name checks the overlay before frozen (eval.py · _load_graph_by_name); and the overlay is threaded into the cold eval subprocess via the ACTIVE_WORKSPACE_DIR env at spawn (job_scheduler.py · _spawn), so the subprocess's own two-pass scan loads overlay code. After that scan, each nodeset's resolved source_file already points at the overlay copy if it redefines that nodeset — and that is what determines how each parallelism is handled:

parallelismwho runs it for an evalhow the overlay reaches it
localin the eval subprocess, in-processautomatic — the subprocess's overlay-first scan loads the instance straight from the overlay source_file
replicatedK per-worker auto_host children the subprocess spawnsautomatic — each worker is spawned from the same overlay-resolved source_file (registry.py · _load_nodeset_as_server); the subprocess owns them, so no cross-process coordination is needed
sharedthe parent backend's long-lived singleton, over HTTPspecial — the eval attaches to a parent-owned server it does not own; see below

Why shared is the only special case. local and replicated nodesets are owned by the eval subprocess — it scans them overlay-first and spawns them itself, so the overlay is picked up for free via source_file. A shared nodeset is different: it is a single long-lived server in the parent backend, shared across sessions, that the eval merely connects to (shared_urls.json, computed in eval.py · start_eval_v2). The overlay cannot just re-point or reload it without disturbing other sessions or tearing down a GPU model.

Shared-overlay handling (TODO #60). So at admit, before the eval starts, the scheduler content-hashes the overlay's nodeset tree against frozen (job_scheduler.py · _prepare_ephemerals, via content_hash.py · hash_nodeset_tree / resolve_overlay_source). If they differ, it spawns a tagged ephemeral auto_host child from the overlay source, rewrites that URL into shared_urls.json so the run attaches to the overlay server, and tears it down by tag on reap — the frozen singleton stays alive for everyone else. If they are identical, no ephemeral is spawned and the run reuses the frozen singleton (saving VRAM). So the shared version a run sees is content-addressed and decided atomically before any worker attaches.

4. Where it deviates from the mental model

Three places where today's code does not match the invariants / the intended manual-only policy. Each is real, shipped behaviour — named here rather than hidden behind the clean model above.

shared auto_host drifts from disk — breaks invariant 1. A shared nodeset's server holds the code it imported at first spawn; an eval attaches to that parent-owned server (eval.py · start_eval_v2 via shared_urls.json) instead of re-importing, so its .py stays stale until a manual /api/components/reload. It is the one process allowed to be a competing, drift-able source of truth.
② Two watchers auto-reload — breaks invariant 4 (the "no automatic reload" policy). graph_watcher.py · run_graph_watch_loop auto-pushes a UI refresh on graph-JSON change (touches no code), and nodeset_watcher.py · run_nodeset_watch_loop hot-reloads nodeset .py on a settled change via registry.py · hot_reload_nodeset_sources. The target is that file changes only signal a reload, never perform one — the actual reload should always be the explicit call above.
③ The targeted watcher is partial — only reloads local nodesets in place; it leaves shared/server nodesets as notify-only and cannot attribute underscore helper modules or deletions (those need the full /api/components/reload). So the only complete reload is the manual full one — the watcher is a convenience, not a guarantee.

5. Implementation map — which function does what

All paths under agentcanvas/backend/. Functions are named, not line-pinned — grep the symbol; line numbers drift, names don't.

5.1 Registry — discovery, import, load (app/components/registry.py)

FunctionHow it's implemented
scan_allbump _scan_cycleunregister_all → pass 1 frozen, pass 2 overlay (same name wins)
unregister_allclear discovered dicts, stop() every auto_host, evict standalone NODE_HANDLERS — this is what makes deletions disappear
_import_module / _import_packagepop any stale sys.modules entry, then exec_module under _ws_{subdir}_{stem}_{scan_cycle} — fresh class, no cache (invariant 2)
ensure_nodesets_for_graphfor each nodeset__ prefix in the graph, dispatch on declared parallelism (replicated → K tagged copies; shared → singleton)
load_nodeset(re)load one nodeset: unload-if-loaded, then local in-process, or auto-route to server mode
_load_nodeset_as_serverallocate K ports, spawn K auto_host --file <source_file> --class … children (tag name#k), each via BaseServer.start
register_remote_nodesetattach to a parent-owned URL via a _RemoteAutoServerShim (no spawn) — the shared attach path
hot_reload_nodeset_sources / _resolve_nodeset_reimporttargeted watcher reload: map changed file → entry, re-import, reload local in place, flag shared stale
load_nodeset_ephemeral / unload_nodeset_ephemeralspawn / tear down a tagged overlay shadow server for the shared-overlay case

5.2 Eval pipeline — submit, schedule, subprocess

FunctionHow it's implemented
eval.py · start_eval_v2endpoint: load graph, compute shared_urls (only shared nodesets), build spec with active_workspace_dir, scheduler.submit
eval.py · _load_graph_by_namejson.loads(path.read_text()) — overlay graphs/{name}.json first, frozen fallback → graph fresh every submit
job_scheduler.py · _admitadmission tick: VRAM / exclusive-GPU checks → _prepare_ephemerals_spawn
job_scheduler.py · _prepare_ephemeralscontent-hash overlay vs frozen; if different, spawn a tagged ephemeral shared server from overlay and rewrite shared_urls.json
job_scheduler.py · _spawnPopen([sys.executable, "-m", "app.eval_subprocess_main", …]), inject ACTIVE_WORKSPACE_DIR, own session/pgid
job_scheduler.py · _reapreap finished subprocess, decrement _shared_consumer_count, tear down the run's ephemerals
eval_subprocess_main.py · _build_local_registry_for_runsubprocess boot: scan_all (overlay-aware) → register_remote_nodeset for shared URLs → ensure_nodesets_for_graph

5.3 Servers, manual reload, watchers, wiring

FunctionHow it's implemented
server/base_server.py · start / _wait_for_healthspawn the server subprocess, block polling /health until 200 (or timeout) — the readiness barrier
server/auto_host.py (-m entry)load the nodeset class from --file/--class and serve its tools over HTTP — every local-as-server / replicated worker / shared / ephemeral server is one of these
api/platform/components.py · reload_componentsthe manual reload endpoint: shutdown_all → scan_all → initialize_all
components/content_hash.py · hash_nodeset_tree / resolve_overlay_sourcehash a nodeset's source tree; decide overlay-vs-frozen for the shared ephemeral
services/graph_watcher.py · run_graph_watch_loopmtime poll graphs/ → broadcast graphs_changed (UI only, no code reload)
services/nodeset_watcher.py · run_nodeset_watch_loopmtime poll nodeset .pyhot_reload_nodeset_sources (automatic, debounced, canvas-guarded)
main.py · lifespanstartup scan_all + initialize_all; start JobScheduler + both watchers
config.py · Settings · state.py · ProcessServices, ExecutionGuardworkspace_dir / active_workspace_dir; registry construction with active_dir; the exec lock the nodeset watcher respects
AgentCanvas docs