Reload & Code Freshness
Disk as source of truth · cold eval subprocess · re-import primitive · manual reload · active-workspace overlay · shared-server drift
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.
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).
- Disk is the source of truth — the frozen
workspace/plus an optional active overlay; nothing else is authoritative. - A reload is a replace, never an in-place mutation — re-execute the file, swap in the new class.
- A run is an immutable snapshot — code is pinned when the run starts; live workers are never updated mid-run.
- Reload is manual-only (intended) — code refreshes when a human clicks Reload or calls the API, never automatically; a file change should only signal a reload, never perform one.
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:
| parallelism | who runs it for an eval | how the overlay reaches it |
|---|---|---|
local | in the eval subprocess, in-process | automatic — the subprocess's overlay-first scan loads the instance straight from the overlay source_file |
replicated | K per-worker auto_host children the subprocess spawns | automatic — 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 |
shared | the parent backend's long-lived singleton, over HTTP | special — 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.
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.
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)
| Function | How it's implemented |
|---|---|
scan_all | bump _scan_cycle → unregister_all → pass 1 frozen, pass 2 overlay (same name wins) |
unregister_all | clear discovered dicts, stop() every auto_host, evict standalone NODE_HANDLERS — this is what makes deletions disappear |
_import_module / _import_package | pop any stale sys.modules entry, then exec_module under _ws_{subdir}_{stem}_{scan_cycle} — fresh class, no cache (invariant 2) |
ensure_nodesets_for_graph | for 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_server | allocate K ports, spawn K auto_host --file <source_file> --class … children (tag name#k), each via BaseServer.start |
register_remote_nodeset | attach to a parent-owned URL via a _RemoteAutoServerShim (no spawn) — the shared attach path |
hot_reload_nodeset_sources / _resolve_nodeset_reimport | targeted watcher reload: map changed file → entry, re-import, reload local in place, flag shared stale |
load_nodeset_ephemeral / unload_nodeset_ephemeral | spawn / tear down a tagged overlay shadow server for the shared-overlay case |
5.2 Eval pipeline — submit, schedule, subprocess
| Function | How it's implemented |
|---|---|
eval.py · start_eval_v2 | endpoint: load graph, compute shared_urls (only shared nodesets), build spec with active_workspace_dir, scheduler.submit |
eval.py · _load_graph_by_name | json.loads(path.read_text()) — overlay graphs/{name}.json first, frozen fallback → graph fresh every submit |
job_scheduler.py · _admit | admission tick: VRAM / exclusive-GPU checks → _prepare_ephemerals → _spawn |
job_scheduler.py · _prepare_ephemerals | content-hash overlay vs frozen; if different, spawn a tagged ephemeral shared server from overlay and rewrite shared_urls.json |
job_scheduler.py · _spawn | Popen([sys.executable, "-m", "app.eval_subprocess_main", …]), inject ACTIVE_WORKSPACE_DIR, own session/pgid |
job_scheduler.py · _reap | reap finished subprocess, decrement _shared_consumer_count, tear down the run's ephemerals |
eval_subprocess_main.py · _build_local_registry_for_run | subprocess boot: scan_all (overlay-aware) → register_remote_nodeset for shared URLs → ensure_nodesets_for_graph |
5.3 Servers, manual reload, watchers, wiring
| Function | How it's implemented |
|---|---|
server/base_server.py · start / _wait_for_health | spawn 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_components | the manual reload endpoint: shutdown_all → scan_all → initialize_all |
components/content_hash.py · hash_nodeset_tree / resolve_overlay_source | hash a nodeset's source tree; decide overlay-vs-frozen for the shared ephemeral |
services/graph_watcher.py · run_graph_watch_loop | mtime poll graphs/ → broadcast graphs_changed (UI only, no code reload) |
services/nodeset_watcher.py · run_nodeset_watch_loop | mtime poll nodeset .py → hot_reload_nodeset_sources (automatic, debounced, canvas-guarded) |
main.py · lifespan | startup scan_all + initialize_all; start JobScheduler + both watchers |
config.py · Settings · state.py · ProcessServices, ExecutionGuard | workspace_dir / active_workspace_dir; registry construction with active_dir; the exec lock the nodeset watcher respects |