Skip to main content
loomcycle
§ release

Filesystem Volumes arrived. Multi-ensemble isolation in one runtime.

A week and a half ago I wrote about a conversation with the Paca maintainer that surfaced a real gap in loomcycle's substrate. Paca runs OpenHands' Docker-Sandbox-per-conversation model: one isolated agent, one fresh container, one working tree. Loomcycle's shape is different: a team of specialised agents on the same feature, sharing context and primitives. Beautiful in theory. The theory hid a bug.

Today's loomcycle hosts the agent loop, the substrate Defs, multi-tenant authz, and the trust boundaries in one Go binary alongside your application. Every file tool was confined to one per-instance jail: LOOMCYCLE_READ_ROOT, LOOMCYCLE_WRITE_ROOT, LOOMCYCLE_BASH_CWD. Three env vars, one shared directory. Run two ensembles in the same runtime and they could read and write into each other's working tree with no operator control. The only fix was to spin up a fresh container per ensemble. That throws away exactly the "one long-lived runtime hosting many agents cheaply" property the runtime exists to provide.

Today, with v1.1.0, that gap is closed.

v1.1.0 ships Filesystem Volumes (RFC AH, Phases 1 through 5). Per-agent ro/rw filesystem roots replace the global jail. Static volumes from yaml plus a runtime-mutable VolumeDef substrate. Run-scoped ephemeral volumes that auto-purge on completion. The legacy jail env vars are retired (breaking). Cross-transport parity across HTTP, gRPC, MCP, TypeScript, and Python. A Web UI tab. A new exp8 example proves the shape end-to-end.

Below: what Volumes look like, the new ephemeral pattern, the breaking change and its migration, and the experiment that makes it all concrete.

The model: a Volume is `{name, path, mode: ro|rw}`

One concept. A Volume is a name pointing at a directory plus an access mode. The file tools (Read, Write, Edit, Glob, Grep, Bash, NotebookEdit) take an optional volume arg and resolve every path relative to that volume's root. An AgentDef declares which volumes it binds to. Operators declare the universe of volumes in yaml. The runtime stitches the two together at run-start.

Static volumes come from a new top-level config block:

volumes:
  default:    { path: /work/sandbox,      mode: rw, default: true }
  shared-ro:  { path: /work/reference,    mode: ro }
  repo-a:     { path: /work/ensembles/a,  mode: rw }
  repo-b:     { path: /work/ensembles/b,  mode: rw }

An AgentDef binds to a subset:

agents:
  ensemble-a-lead:
    allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, Agent]
    volumes: [repo-a, shared-ro]      # confined; cannot touch repo-b or default

That's the whole user-facing surface for Phase 1. The runtime takes over from there.

The load-bearing invariant: spawn confinement

An ensemble lead spawns sub-agents. Those sub-agents must not escape to the parent's neighbour's working tree. The rule:

A sub-agent's volume set ⊆ its parent's. An unbound child inherits the parent's policy verbatim. A child that declares volumes is narrowed to (child-declared) ∩ (parent's active bindings), with ro/rw resolving to the more restrictive of the two. A child that shares none of the parent's volumes runs denied: every file tool refuses. It does not silently fall back to a global jail.

The same shape as the existing allowed_hosts caller-authoritative narrowing for network egress, applied to the filesystem. The host policy was the model; the volume policy follows it move for move. (And the TOCTOU-safe path-containment code in resolveInsideRoot didn't change a byte. Volumes only change which root is passed in. The hardening was already there.)

Dynamic VolumeDef substrate (Phase 2a): a tenant provisions volumes mid-run

The static model (Phase 1) requires every volume to be pre-declared in operator yaml. That's right for an operator who knows what their ensembles need ahead of time. It's wrong for a tenant who wants repo-c at 2am because a new job came in.

Phase 2a adds VolumeDef: a runtime-mutable, tenant-scoped substrate that mirrors the other Def families (AgentDef, MCPServerDef, SkillDef). A tenant calls VolumeDef op=create name="repo-c" mode=rw, the runtime provisions <dynamic_root>/<tenant>/repo-c with mkdir, persists a row, and the new volume is bindable from that point forward.

The trust-boundary discipline is the load-bearing detail.

Run-scoped ephemeral volumes (Phase 2b): create, use, vanish

Phase 2a's volumes are persistent. A tenant creates one, it lives until the tenant deletes it. That's right for "I always want a scratch volume in this tenant." It's wrong for "the dispatcher agent needs a fresh working tree for this run only, and I never want to think about cleanup."

Phase 2b adds ephemeral as a flag on VolumeDef op=create. An ephemeral volume lives for exactly the lifetime of its creating run.

VolumeDef op=create name="lc-src" mode=rw ephemeral=true

The runtime provisions <dynamic_root>/_ephemeral/<run_id>/lc-src (the _ephemeral segment is reserved; the run_id segment guarantees no cross-run collisions). When the top-level run reaches a terminal state, success or failure, the ephemeral volume auto-purges. os.RemoveAll behind four fences (re-derive the path, EvalSymlinks, assert-inside-root, prefix-check the _ephemeral/<run> segment, refuse to delete the root itself). A singleton sweeper backstops the inline purge for runs that crashed before reaching the terminal handler. Paused runs are skipped so a snapshot-and-resume keeps its working tree.

Run-tree isolation: the ephemeral volume set is created fresh per top-level run, inherited unchanged by sub-agents (so the dispatcher and the ten reviewers it spawned all see the same lc-src), but never crosses between two top-level runs. Two ensembles each get their own lc-src. They never collide.

exp8: a self-contained code-review fan-out

The killer demo. examples/exp8-ephemeral-volume-review ships as a self-contained directory: loomcycle.yaml, run.sh, .env.local.example, a Python trigger driver, and a comprehensive README. Clone the repo, cd, fill .env.local, run.

The topology, in one block:

POST /v1/runs → exp8-dispatcher (rw default + dynamic-root)
  ├─ VolumeDef op=create name="lc-src" mode=rw ephemeral=true  → <EPHEMERAL_PATH>
  ├─ Bash: git clone https://github.com/denn-gubsky/loomcycle <EPHEMERAL_PATH>/loomcycle
  ├─ Agent op=parallel_spawn: 8 × exp8-reviewer
  │    (each inherits the ephemeral volume; Read/Glob/Grep only)
  │    each writes Memory key review:<slice>:findings
  └─ Agent op=spawn: exp8-consolidator → reads Memory → Write work/exp8/review-report.md
← run ends → ephemeral volume auto-purged (work/dynamic/_ephemeral/ is empty)

One agent run, one MCP-free orchestration, eight concurrent reviewers, one consolidator, one report on disk, zero cleanup.

Contrast with exp7

exp7 uses external MCP fan-out: a Claude Code operator outside loomcycle calls spawn_runs from one MCP tool call, the reviewers run against a pre-cloned static read-only volume, the operator drives the fan-in barrier and writes the report. The repo lives outside loomcycle.

exp8 is self-contained: a loomcycle dispatcher agent owns the full lifecycle. Provision the workspace, clone the repo, fan out via Agent op=parallel_spawn (an in-process join barrier, no MCP round-trip), consolidate, exit. The volume auto-purges. The operator runs one POST and gets a report. Two patterns, two different jobs.

Use exp7 whenUse exp8 when
the repo is large or shared (pre-cloning matters) you want zero-setup, zero-cleanup on-demand
an operator drives the fan-in (Claude Code stays the conversation) a single trigger is enough; loomcycle owns the loop
multiple operators share the same workspace each review needs an isolated workspace that disappears
your reviewers compose with other MCP tools the operator brings your reviewers compose with each other and nothing else

Phase 3 (BREAKING): the legacy jail is retired

The Phase 1 design left a fallback: an agent that didn't bind to any Volume kept using the legacy READ_ROOT / WRITE_ROOT / BASH_CWD env vars. That fallback was for backward compatibility during the migration window. With Phases 1, 2a, 2b shipped and JobEmber.ai running loomcycle as a no-disk sandbox in production, the fallback is gone in v1.1.0.

The three env vars are removed. Volumes are the sole filesystem mechanism. An agent that is not bound to any volume has no filesystem access: every Read, Write, Edit, Glob, Grep, Bash, NotebookEdit call refuses. Sandbox-by-default. The same shape as "no allowed_hosts means no egress."

A deployment that still sets any of the retired env vars fails at config-load with a migration hint. No silent denial; you see the error at boot and from loomcycle doctor.

The migration is one-line:

# Before (v1.0):
LOOMCYCLE_READ_ROOT=/work/sandbox
LOOMCYCLE_WRITE_ROOT=/work/sandbox
LOOMCYCLE_BASH_CWD=/work/sandbox

# After (v1.1.0):
volumes:
  default: { path: /work/sandbox, mode: rw, default: true }

The 3 bundled examples (exp1, exp4, exp7), the README deployment-postures table, and the docs/PLAN.md recipes are all migrated off the env vars in this release. exp7 in particular shows the read-only pattern: its reviewers bind to a single default volume with mode: ro so the cloned repo can be read but not modified.

Phase 4 and 5: Web UI and cross-transport parity

Phase 4 ships a Volumes tab in the Web UI. List, create, delete; ephemeral volumes show up with their owning run-id and an "auto-purge on run end" badge. The same shape as the existing Agents, Channels, and Memory tabs. (Operators who never open the Web UI lose nothing; everything is also reachable from the wire.)

Phase 5 fills in cross-transport parity. The VolumeDef surface (create, delete, purge, list, get) is identical across HTTP, gRPC, MCP, the @loomcycle/client TypeScript adapter, and the Python adapter. Same wire shape, same error codes, same auth scopes. A Python client and a TypeScript client can both provision an ephemeral volume against the same loomcycle instance and never know the other one is there.

In numbers: @loomcycle/[email protected] publishes to npm when the v1.1.0 tag fires. The Python adapter ships next as [email protected] when the python-v0.9.0 tag drops separately (different release cadence).

What this unlocks

Three concrete things, in order of impact:

  1. Two ensembles in one runtime, each with their own workspace, never colliding. The shape the launch-week Paca conversation surfaced and the runtime didn't have an answer for. Now it does. A Paca-style "one agent per feature, in its own container" pattern can stay; alongside it, in the same loomcycle binary, a "team of specialised agents collaborating on a feature, in their own workspace" pattern works too. Pick per workload.
  2. The clone-and-throw-away pattern as a primitive, not a recipe. Before: an operator script wraps loomcycle, creates a temp dir, calls loomcycle pointing at it, captures the result, deletes the temp dir, hopes nothing crashed in between. After: an agent inside loomcycle does it. VolumeDef op=create ephemeral=true, Bash: git clone, work, exit. The volume vanishes. No wrapping script needed.
  3. The substrate as a measuring instrument. The Paca conversation taught me what loomcycle was missing. The 133-min slow-Qwen run taught me what the heartbeat and compaction couldn't yet do. Every substantial integration conversation since launch has surfaced at least one primitive worth adding. The runtime keeps getting more honest about the shapes real users want; that's the only metric that matters.

The trust posture didn't slip

v1.1.0 makes the filesystem story stricter, not looser. Sandbox-by-default. Per-agent ro/rw. Spawn-narrowing in the same shape as the network-side host allowlist. The TOCTOU-safe path-containment unchanged. The ephemeral-purge guarded by four fences. The new wire surface unable to inject a path because the path is runtime-derived. The kind of careful that "we added a new feature" releases usually aren't.

How to actually run it

# Upgrade
brew upgrade loomcycle           # macOS / Linux
docker pull denngubsky/loomcycle:1.1.0

# Migrate (if you had the legacy env vars set)
# Remove LOOMCYCLE_READ_ROOT / WRITE_ROOT / BASH_CWD from your env.
# Add a `volumes:` block to your loomcycle.yaml:
#   volumes:
#     default: { path: /work/sandbox, mode: rw, default: true }

# Or try exp8 directly
git clone https://github.com/denn-gubsky/loomcycle
cd loomcycle/examples/exp8-ephemeral-volume-review
./run.sh serve
# in another terminal:
python3 work/exp8_run.py

Full release notes for v1.1.0 in REVISIONS.md. The RFC's design narrative (including the Phase 1 → 5 phasing rationale and the per-phase test-coverage discipline) lives in the loomcycle-internal repo's RFC archive; the public companion is the implementation across PRs #510, #511, #512, #513, #514, #515.

Companion reading: the v1.0 release post (where the Paca conversation that produced this work is documented), agent ensembles (the multi-agent shape Volumes now isolates cleanly), exp7 (the external MCP fan-out pattern that contrasts with exp8's self-contained one).