ADR-components-009
Active-workspace overlay for experiment isolation
Context
Architect-pipeline skills (/architect:myloop:revise, /architect:adas-subagent:implementer, the debug/experiment variants for each, etc.) mutated <repo>/workspace/{graphs,nodesets}/ in place to test agent revisions. After a handful of iters the live workspace was dirty — there was no reliable way to tell which graph or nodeset was the "faithful baseline" vs an architect experiment, and the per-iter workspace_snapshot/ dirs were after-the-fact copies rather than the substrate the eval actually loaded. This blocked clean A/B comparison, polluted git working trees, and made cross-iter replay ambiguous (load which version of mapgpt.py?). The MapGPT v1 trace was the proximate motivator: 9 iters of in-place edits left workspace/nodesets/mapgpt.py at iter_8's QD-planner state with no easy way to recover the 4-22 paper-aligned baseline.
Decision
Introduce a two-tier workspace with overlay semantics. <repo>/workspace/ is the frozen workspace — faithful baseline, read-only for architect skills (still user-editable via canvas UI). Each iter owns an active workspace at outputs/design_runs/{graph}/v{N}/iter_{M}/active_workspace/ with the same subdirectory shape as frozen (graphs/, nodesets/, nodesets/server/, nodes/, policies/, hooks.json); each iter's active_workspace/ is a complete mutation set vs frozen (not a chained diff), produced by cp -r {parent_iter}/active_workspace/. {iter}/active_workspace/ then layering this iter's edits on top. WorkspaceComponentRegistry gains an optional active_dir constructor arg; scan_all() runs two passes — frozen first, then active — and register_node()'s existing last-write-wins on NODE_HANDLERS and _discovered_nodesets[name] provides Python-resource override for free. For non-Python resources, two new helpers resolve_graph_path(name) and resolve_exp_yaml_path(name) perform active-first then frozen-fallthrough lookup; _load_global_hooks() does full-file override (active hooks.json wins, else frozen). Settings.active_workspace_dir: str | None (env var ACTIVE_WORKSPACE_DIR) wires this in at startup with None = no overlay = bit-identical to pre-change behaviour. /api/eval/v2/start accepts active_workspace_dir per request; JobScheduler._spawn() propagates it to subprocesses via env so ADR-eval-003's subprocess-per-run scheduler picks up the overlay at fresh-Settings init. /experiment:run --workspace=<abs-path> exposes the same override on the CLI side. All architect write-skills are migrated atomically — they never write to <repo>/workspace/ again. .exp.yaml is excluded from overlay (it's user config, not architect mutation) and is read from frozen with a top-level convenience copy under {ITER}/.
Alternatives
(a) Full workspace copy per iter — rejected, bulky on disk (entire workspace/ is ~100s of MB once policies/checkpoints land there) and most iters touch only 1–2 files; copying everything obscures what each iter actually changed. (b) Per-skill before/after snapshot with revert — the pre-change behaviour: each skill wrote a .before file, mirrored edits into workspace_snapshot/, then optionally reverted. Rejected — race-prone (any skill or user touching workspace/ between snapshot and revert breaks replay), no way for replay to "point at iter_3's logical state", and the snapshot was a side-effect not the substrate eval read. (c) No isolation (status quo) — rejected; the MapGPT v1 trace already demonstrated the dirty-workspace failure mode. (d) Per-skill chained diff (each iter stores iter_N.diff against parent) — rejected; replay would require walking the chain to materialise effective state, and merges across forks (e.g. iter_5 forking iter_3 while iter_4 is "stale lineage") become surgery. The "complete mutation set per iter" rule is cp -r parent + this iter's edits, so each iter dir is self-contained. (e) Move shared singletons (Prismatic VLM etc.) out of workspace/ first — deferred at the time of this ADR; documented as a known caveat (pre-loaded singletons live in the parent backend's lifespan, immune to per-run overlay without backend restart). Resolved 2026-05-15 (TODO #60) by JobScheduler._prepare_ephemerals: when an admit-time content hash of an overlay's parallelism="shared" nodeset source differs from frozen, the scheduler spawns a tagged ephemeral auto_host child loaded from the overlay path and rewrites shared_urls.json to route this eval to it; the frozen singleton stays alive for sibling sessions. Reaping releases the ephemeral via WorkspaceComponentRegistry.unload_nodeset_ephemeral. Caveats remaining: first iter touching a shared nodeset pays the spawn cost; VRAM doubles for the eval's duration.
Rationale
Leveraging register_node()'s pre-existing last-write-wins gives the Python-side override semantics with zero additional code in node registration — only the scan loop changed. The active-first/fallthrough helpers (resolve_graph_path, resolve_exp_yaml_path) consolidate non-Python lookups behind one source-of-truth method on the registry, eliminating the hardwired _WORKSPACE = parents[5] / "workspace" constants previously scattered through eval.py. Settings.active_workspace_dir = None default + env-var binding means existing deployments and the user's :8000 canvas backend keep frozen-workspace-only behaviour bit-identical — the overlay is opt-in per-request, never global. Subprocess propagation via env (_spawn()) reuses ADR-eval-003's fresh-Settings-per-subprocess pattern without touching the eval runner. The "complete mutation set vs frozen" property (not a chained diff) makes each iter dir a self-contained replay artifact: /experiment:run --workspace=outputs/design_runs/{graph}/v{N}/iter_{M}/active_workspace faithfully reproduces what that iter ran, regardless of what frozen drifts to (modulo singleton constraints documented in files-contract.md). Atomic migration ("Option A": all write-skills go to active_workspace from day one, never to frozen) avoids a forever-fork between "old skills writing to workspace" and "new skills writing to active_workspace" — frozen becomes permanently read-only for architects, restoring the dirty/clean invariant. Backward compat for pre-migration iters is a read-skill fallback rule (active_workspace/ → fall through to workspace_snapshot/) documented in files-contract.md — no migration script needed, since old iters are read-only history.
Affected docs
core/decisions/components/index.md, core/decisions/index.md, core/codebase-map.md, core/architecture.md, capabilities/customizable-node-system.md, capabilities/isolated-runtime-environments.md, design-docs/batch-eval.md, design-docs/coding-agent-backend-surface.md, .claude/commands/architect/_common/files-contract.md (and all 18 architect skill files migrated in this change), .claude/commands/experiment/run.md, .claude/experiment/bin/submit.py, core/roadmap.md (changelog).