Skip to content

Let a tong mount a persistent named volume - #94

Merged
CrypticSwarm merged 15 commits into
masterfrom
tong-named-volumes
Oct 11, 2026
Merged

CrypticSwarm merged 15 commits into
masterfrom
tong-named-volumes

Conversation

@CrypticSwarm

Copy link
Copy Markdown
Owner

Stacked on #93, which is stacked on #92. Merge those first, in that order, and GitHub will retarget this to master. The new volumes: line in the approval prompt relies on #93's escaping, and the docs example uses #92's resources.gpus.

Why

A tong had nowhere to keep state. A shared tong is recreated whenever its definition changes or its container is gone, so anything written inside it is lost. For a model server like Ollama that means re-downloading a multi-GB model store. Tongs never bind raw host paths, so make run_ollama's bind of ollama/ is not an option. This adds a docker named volume that the launcher names and scopes, so the Ollama tong being built outside this repo can keep its models. The existing Ollama make targets are unchanged.

What changes

  • volume:<name>:/target[:mode] mount word:
    • The name is at most 64 characters from [A-Za-z0-9.-], starting with a letter or digit. The target is required and follows the same rules as a workspace target (absolute, no whitespace, not /, no overlap with the tong's reserved paths or its other mounts).
    • A volume can be mounted only once per tong.
    • parse_mount keeps its (word, target, mode) shape. The name comes from mount_volume_name, and both share one splitter, so validation, argv assembly and the approval prompt agree.
  • Naming and scope: the docker volume is swarmforge-volume-[<hint>-]<digest>_<name>.
    • The digest is the first 32 hex characters of SHA-256 over [scope class, scope path, raw tong filename]. The hint is the sanitized filename cut to 32 characters, for docker volume ls only.
    • Scope by layer: org uses the org's tongs directory, workspace uses the checkout's top level with symlinks resolved, user/repo are local to the machine.
    • Two tongs share a volume only with the same filename and the same scope. A 128-bit digest over an unambiguous key means a definition can't brute-force or sanitize its way onto another org's, checkout's or tong's volume. A first draft with a shorter, concatenated token allowed both, and review caught it.
    • An org or workspace tong with a volume but no directory to scope it by is refused before any docker call.
  • New refusal: a workspace-sourced shared tong may not mount a volume. Its container is global and reused by sessions in other checkouts, which would then mount this checkout's volume. This follows the existing refusal of a shared tong that mounts the workspace.
  • Lifecycle: docker creates the volume on first use. The launcher never removes one, not on teardown and not when a shared tong is recreated. No new docker seam was needed.
  • Approval prompt: a workspace tong's volumes get their own line: volumes: <names> (persistent: outlives the tong and reuses any data left by an earlier definition or checkout at this path).
  • Docs: docs/tongs/definitions.md covers the grammar, naming, the scope per layer, and that the workspace scope is keyed by path: a later checkout at the same path inherits the volume, even if it is a different repository, just as it inherits approvals. It also covers cleanup with docker volume rm, and how this differs from the still-unwired interface.kind: volume. It also has a short GPU model-server example. That tong is named models because a tong named ollama would clash with make run_ollama on the same network. docs/tongs/README.md and docs/tongs/approval.md are updated to match.

Testing

  • make test: 996 tests pass (959 on the base). Each commit passes on its own. ruff check is clean.
  • Tests cover the grammar, including the edge cases, refused forms and length caps. They check that validation and argv agree, and that targets can't overlap reserved paths or each other. Five volume names are pinned to literal expected values (local, org and workspace scopes, a non-ASCII filename, a truncated hint), so a refactor can't silently point existing volumes at new, empty ones. Other tests cover the scope per layer on every orchestration path (session, shared, org-scoped shared), the refusals, teardown never removing a volume, a recreated shared tong keeping its volume, and the prompt line.
  • Not exercised here: a real docker run against a daemon. A manual check is to run the models example from the docs twice, with an edit to the definition in between, and confirm pulled models survive.

Follow-ups not in this PR

  • Detect an already-existing volume, or stamp volumes with the approving definition's hash, so the prompt can tell "new" from "reused".
  • Port publishing to the host, and tong-declared anvil env (for example OLLAMA_HOST). Both are needed before the Ollama handling here can be retired.

A tong had nowhere to keep state: anything it wrote inside its
container was lost whenever the container was recreated, which for a
shared tong happens on every definition change. A model store such as
ollama's is tens of GB to download again. Tongs may never bind a raw
host path, so the new `volume:<name>:/target[:mode]` magic word asks
for a docker named volume that the launcher names, and docker creates
on first use and keeps when the container goes.

The docker name is `swarmforge-volume-[<hint>-]<digest>_<name>`. The
digest is the volume's identity: the first 32 hex digits of a SHA-256
over the tong's scope and its raw name. Joining a scope token and a
sanitized name with `-` would let distinct tongs collide, both by
aliasing (`github.` and `github` sanitize alike) and by brute-forcing
a short digest. The hint is the sanitized name, kept only so
`docker volume ls` is readable. Volume names may not contain `_` and
the digest has a fixed width, so the last `_` always splits the
volume name off. A volume mount must be given a scope; choosing one
per layer comes next.

- `parse_mount` keeps returning `(word, target, mode)` so its callers
  are untouched; `mount_volume_name` reads the name through the same
  grammar, so validation and argv assembly cannot disagree on it.
- A volume target follows the workspace target's rules: it must be
  absolute, must not resolve to `/`, and may not overlap the tong's
  wiring or its other mounts.
- A tong mounts each volume once. A second target for the same data,
  possibly with a different access mode, is much more likely a mistake
  than something anyone needs.
- Volume names are capped at 64 characters and the readable hint at
  32, keeping the docker name well inside a 255-byte directory name.
A volume named only after its tong would hand one org's data to
another org that ships a tong with the same filename, and one cloned
repo's data to any other clone. `volume_scope` now picks the scope
that goes into the volume's digest from the layer the tong came from:

- org: the org's tongs directory, on both lifecycles
- workspace: the checkout's top-level path, with symlinks resolved,
  so every session in the checkout shares the volume whichever
  subdirectory it starts from. The path is the key, as it is for
  approvals, so a later checkout at the same path inherits it.
- user and repo: this machine, so a repo tong and the same-named user
  tong it replaces in the merge share one volume

An org or workspace tong that mounts a volume refuses to start when
there is no directory to scope it by, instead of falling back to the
machine-wide scope.

A workspace-sourced `shared` tong that mounts a volume is refused.
Its container name is global but its volume belongs to one checkout,
so a session in another checkout that ships the same definition would
reuse the container with the first checkout's volume still mounted.
Refusing it follows the existing rule against a `shared` tong
mounting the workspace.

Teardown never touches a volume, and a shared tong recreated after a
definition change mounts the same volume again.
A workspace tong that mounts a volume leaves data on the host's docker
after the tong is removed. The prompt already lists the raw
`volume:...` mount, but nothing in that line says the data persists.
The prompt now adds a `volumes:` line that names each volume and says
it is kept.

The prompt does not show the resolved docker volume name. The launcher
derives that name from the tong's name and the checkout, so the
definition cannot choose it, and it would not help the reviewer decide
whether to approve. Leaving it out also keeps `privilege_summary` a
function of the definition alone. The docs explain how volumes are
named, for anyone who wants to find or remove one.

The gate runs before validation, so a malformed volume entry is left
off the `volumes:` line rather than raising. It still appears among
the raw mounts, and validation refuses it before anything starts.
The definition reference covers the `volume:<name>:/target[:mode]`
mount. It describes how the docker volume is named and scoped per
layer, explains that the launcher never removes a volume and how to
remove one with `docker volume rm`, and says why the mount is
unrelated to the not-yet-wired `interface.kind: volume`. The lifecycle
notes now tell `shared` tong authors where to keep state that must
survive a recreation, and the approval page mentions the new
`volumes:` line.
With GPUs and volumes both available, an ollama-style model server can
run as a tong: a `shared` `port` tong with `resources.gpus` and its
model store in a `volume:` mount, so recreating the container does not
download the models again. The definition reference now shows one
complete, minimal example.

The example tong is named `models` rather than `ollama`. `make
run_ollama` already answers to `ollama` on the same network, so a tong
by that name would collide with it.
Base automatically changed from approval-prompt-escape to master October 9, 2026 01:55
Conflicts:
  swarmforge/anvil/orchestrate.py
    - run_with_tongs docstring: this branch's note that named volumes
      are never removed vs. master's SIGHUP/SIGTERM and teardown_guard()
      wording -> kept master's exit/teardown text and added the volume
      note after it
A workspace tong's filename reached stderr raw through the merge,
refusal and readiness warnings; only the skipped-file warning was
escaped. Escaping inside `tongs.warn` covers them all, and the git-dir
and `tongs validate` messages now route through the same escape.
`_has_socket_mount` was `_mounts_word` with the word fixed. Move the
general check down to `tongs.mounts` and have the socket, workspace and
volume checks all use it.
`parse_mount` dropped the volume name, so every overlap check re-parsed
the new mount and each placed one to get it back. Return the name from
the parse and carry it in the placed entries instead.
Volumes resolved symlinks in the workspace path but approvals did not,
so one checkout reached two ways needed two approvals yet shared one
volume. Both now use `workspace_key`. A missing org or workspace dir is
refused once, in `tong_mount_specs`, instead of at three layers.
`tong_volume_name` repeated `dir_hint`'s truncate-and-trim rule. Split
that rule out as `names.name_hint` and use it for both.
A linked worktree's git dir is bind-mounted at its host path beside the
workspace, outside the definition's own mounts, so the overlap check
never saw it. A volume at /home could enclose it, and docker would leave
its mountpoints inside the persistent volume.
The prompt listed bare volume names, so a reviewer could not see where
the data lands or which existing docker volume it reuses. It now names
both and says that concurrent sessions mount the same volume.
State that every concurrent session mounts a tong's volume, that the
user and repo layers share one across checkouts, and that docker seeds a
volume from the image only once, so targets should hold data only.
The never-removed, workspace-shared-refusal and scope rules were each
restated across several docstrings and doc pages. Keep one copy of each
and cut the rest back.
@CrypticSwarm
CrypticSwarm merged commit a96e3e2 into master Oct 11, 2026
4 checks passed
@CrypticSwarm
CrypticSwarm deleted the tong-named-volumes branch October 11, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant