Repository virtualization
Each work unit branch gets a physically isolated working directory. Agents write files, build, and test in their own sandbox — there is no shared working directory and no file-lock contention between concurrent agents.How it works
The workspace root is configured viaWorkspace:RootPath in appsettings.json. For
every branch, Studio derives a directory path by sanitizing the branch ID — any
character invalid in a filesystem path (including /, the conventional branch
separator) is replaced with _:
To get the effective path for any branch programmatically:
- REST:
GET /studio/workspace/path?branchId=<branch> - MCP:
nm_v1_workspace_pathwith{ "branchId": "..." }
{ "branchId": "...", "workingDirectory": "...", "exists": true }
Branch seeding
When a new branch directory is initialized, Studio tries three strategies in order:- Scoped CAS materialization — if the work unit declares
FileScopeglob patterns and a CAS snapshot exists, only the matching paths (plus project structure files such as.csprojandpackage.json) are extracted from the content-addressable store. This keeps branch directories small when agents only need a subset of the repository. - Seed from another branch — if
seedFromBranchIdis specified on work-unit create, the source branch directory is copied as-is. - Main-branch reconstruction — for the
mainbranch specifically, Studio reconstructs fromWorkspace:SeedRepositoryPathvia CAS snapshot or a full directory copy.
Keeping the CAS snapshot current
The CAS snapshot (RepositorySnapshot — a flat path→blobId map, plus an
Add/Replace/Delete op-log) is a best-effort audit/reconstruction trail derived
from the seed repository’s on-disk content — it is not the source of truth during
a run; the per-branch working directory is. Two things advance it:
- Bootstrap (
RepositoryImportService.EnsureBootstrappedAsync) — walks every file once and records a Generation-0 snapshot. Fires automatically the first time a goal is created against a repository, then intentionally does nothing on any laterGoalCreation/StartupRecoverycall for that same repository in the same process — a one-time seed is all those need. - Forced resync (
RepositoryImportService.ForceSyncAsync) — re-diffs the repository’s current disk content against the last snapshot and records a successor snapshot if anything changed, regardless of whether it was already bootstrapped. This is what keeps the snapshot from going permanently stale after the first goal. It fires:- Automatically, right after a merge’s changes are written back to the repository
— scoped specifically to the global default repository
(
Workspace:SeedRepositoryPath); a multi-repo work unit’s own registered repository does not currently trigger an automatic resync this way. - On-demand via
POST /studio/workspace/switchor thenm_v1_workspace_switchMCP tool, whether or not the path actually changed.
- Automatically, right after a merge’s changes are written back to the repository
— scoped specifically to the global default repository
(
A resync never touches an already-materialized file in any branch directory —
branch initialization no-ops the instant a branch directory is non-empty, and the
only other snapshot-consuming read path (on-demand
FileScope fallback fetch)
only ever fires for a file that branch has never touched before. So a live resync
cannot disturb a running agent’s own in-progress work; the one observable effect is
that a scoped branch’s first-ever fetch of a not-yet-materialized file may see
fresher content than it would have before the resync ran.Scoped materialization
When a work unit’sFileScope property contains one or more glob patterns, the
materializer extracts only files matching those patterns from the CAS blob store.
Project-structure files (.csproj, package.json, Cargo.toml, go.mod, etc.) are
always included regardless of scope so build tools can resolve the project graph.
This is the primary mechanism for keeping short-lived worker branches lean — an
agent working on src/Auth/** does not need to materialize the entire repository.
Concurrency model
Each work unit has its own branch and therefore its own directory. The CAS blob store (underWorkspace:CasRootPath, defaulting to
{SeedRepositoryPath}/.nodalmerge/cas) is the shared read-only layer. Per-branch
directories are the write-isolated layer — parallel agents never compete for file
locks.
Workspace:MaterializerConcurrency controls the number of parallel I/O threads used
during CAS reconstruction (default: 4). Increase this on machines with fast NVMe
storage and many concurrent agents.
Branch directory cleanup (WorkspaceCacheManager)
Branch working directories are treated as ephemeral cache entries — any evicted
directory can be reconstructed later from the latest repository snapshot + CAS.
WorkspaceCacheManager runs a best-effort orphan sweep automatically at host startup
(Completed/Merged/Cancelled work units only), and exposes REST endpoints for
manual control:
A
Cancelled work unit is always safe to evict — its changes were never merged, so
there’s nothing to preserve. A Completed/Merged work unit is only evicted once
the repository’s own snapshot postdates the work unit’s last update — i.e., once a
resync (above) has actually captured that work unit’s contribution, so reconstructing
later won’t lose anything. For a multi-repo work unit, this check resolves the work
unit’s own registered repository rather than always the global default, so
eviction/rematerialization checks the right repository’s snapshot.
This is currently REST-only — no VS Code UI panel or MCP tool surfaces branch
directory count, disk usage, or manual evict/materialize/gc today. It’s intentionally
automatic-only for typical use.
Observability
IfWorkspace:SeedRepositoryPath, the CAS blob store, or the repository op-log
service aren’t all configured together, the CAS dual-write (blob + op-log write,
alongside every file write/delete in a branch directory) is silently skipped — this
is a legitimate, common, intentional deployment choice, not a misconfiguration
(plenty of setups don’t need the audit trail at all). The first time this happens, an
Information-level log line is written naming which of the three pieces is missing;
it is not repeated on every subsequent file operation, and stops appearing entirely
once configuration is completed at runtime (e.g. via POST /studio/workspace/switch).
Repository binding and re-linking
Every workspace folder you open is bound to a registered repository — its repository room, where that repo’s work units, proposals, findings, and constraints live and replicate. Studio derives the repository’s identity deterministically from its root commit history, computed locally with no server round-trip: the same clone on two machines resolves to the same room, and a fresh checkout binds correctly even with the room server offline. Remotes are not part of the identity — they only break ties between forks. Binding usually just works, and Studio records how it resolved so it can be trusted later:
Studio never silently re-resolves an identity it has already resolved. Once a folder
is bound, it stays bound until you ask to change it. This is deliberate: re-resolving
“helpfully” on every open is how a workspace silently drifts into the wrong room. The
Artifact Explorer shows which repository room you’re currently in, so a mismatch is
visible rather than hidden.
Re-linking
When the binding is wrong — the folder matched the wrong repository (a fork, a moved clone, an ambiguous case), or nothing is registered yet — runNodalMerge: Re-link Repository… (or the Re-link action in the Artifact Explorer). It offers whichever
of these fits your situation:
Bindings are per workspace folder. If you switch the folder VS Code has open, Studio
notices and offers to restart the host so the new folder binds cleanly rather than
running against the previous one’s room.
Git integration
TwoWorkspaceOptions flags control whether Studio acts on the filesystem changes
agents produce:
Both are intended for headless CI/CD pipelines — see
Guides → Headless peer for the typical configuration
pattern.
Configuration reference
All keys live underWorkspace in appsettings.json: