Skip to content

ADR-0039: The env directory seam

Status: accepted (2026-09-08). Supersedes the naming half of ADR-0007; the machine-wide workspace and per-env manifests are unchanged.

Decision

An env directory is <env-name>_<abi-tag>, the seam is an underscore, and the ABI tag keeps its version dots. The tag is a directory name and nothing else, so it is not run through the pixi name sanitizer. Older spellings are adopted where they stand, never renamed and never orphaned.

geometrypack-nodes_py313-torch2.8-cu128
└──────┬─────────┘ └────────┬────────┘
   env name              ABI tag
  • <env-name> is the pack folder with ComfyUI- stripped and lowercased, plus -<subdir> when the config is not at the pack root (environment/cache.py:get_env_name). Every character outside [a-z0-9-] still collapses to a dash, because this half comes from an untrusted folder name on disk.
  • <abi-tag> is py<ver>-torch<major>.<minor>-<backend>, backend one of cu128, rocm63, mps, cpu, notorch. Generated by comfy-env from version numbers and a fixed vocabulary, never from disk, so it does not need sanitizing and does not get it.
  • legacy_dir_names() returns the previous spellings. get_env_manifest_dir, cmd_gc's referenced set and the workspace orphan notice all consult it.

Context

The seam used to be a dash, and so did everything else. get_env_name joins the pack to its config subdirectory with -, _abi_tag joins its own fields with -, the pack name contains -, and the tag ran through _sanitize_pixi_name, which rendered a version dot as - too. One character meant four things:

geometrypack -   nodes  -  py313 - torch2 - 8 - cu128
     pack    ^  subdir  ^   tag fields    ^ version dot

Three consequences, in ascending order of seriousness.

It cannot be read. torch2-10 is torch 2.10 or torch 2 build 10, and nothing in the string says where the pack stops.

It cannot be parsed, and something tried. The macOS libomp dedupe tested "torch" in libomp against the full path. Every env directory contains torchN-M, so every candidate was classified as torch's own and the dedupe never ran on any machine. Fixed at the call site (environment/libomp.py), but the format invited it.

It can collide. ComfyUI-Foo-Bar with a root config and ComfyUI-Foo with a config in bar/ both derive foo-bar. Inside one install that is a hard error (install/workspace.py). Across two installs sharing the machine-wide root it is silent thrash: each re-derives, the identity seal mismatches, both rebuild, forever.

The underscore is safe precisely because the sanitizer collapses it out of every name component. It cannot occur in either half by construction.

What this decision is not

It is not a size optimisation, and the measurement is worth recording so nobody re-opens it on those grounds. On Windows the deepest real path inside an env is a Qt licence dump with nested node_modules, 224 characters below the env directory. Against a %LOCALAPPDATA% root the shortfall past MAX_PATH is about 70 characters, and the largest saving any naming scheme can offer is 34 (deleting the name entirely). No name shortens its way under the limit. COMFY_ENV_ROOT recovers roughly 43 characters, more than any naming scheme, and is the lever that actually exists. Hashed and abbreviated schemes were assessed and rejected: they buy nothing measurable and cost the one artifact a human reads when deciding what to delete.

Consequences

  • No rebuild. An env is byte-identical under either spelling: nothing inside records its own path (no absolute paths in pixi.lock or the generated manifest), and payloads are hardlinks into the shared rattler cache. Verified: 16 of 16 envs on one machine's live stack adopt, none orphan.
  • Adopt, do not rename. Renaming a directory whose DLLs a running worker holds open fails on Windows with a sharing violation. There is nothing to win by entering that race, so the old directory is used in place.
  • Every consumer of _env_dir_name must also consult legacy_dir_names. Miss one and comfy-env gc sees a live env under its old spelling, finds it absent from the referenced set, and offers to delete it. This is the sharp edge of the decision and the reason the alias is a named function rather than an inline string.
  • The alias is removed at 0.6.0, by which point every env has been rebuilt for an unrelated reason at least once.
  • The derivation collision is fixed alongside this, not by it. A separator makes the string readable; it does not stop two packs deriving one name. env.stamp.json now records source (the pack folder plus the config path inside it) and validate_env_stamp refuses a bind when it disagrees, naming both sides. That is deliberately not encoded in the directory name: doing so would lengthen every name to catch a case the stamp catches for free, and would still not stop the two from sharing a directory, only from being confused for one another once they had.

The precedent this is answering

The last naming change did not have an alias. Four pre-tag directories totalling 26 GB, 37 percent of a 70 GB store, were still sitting on the maintainer's own machine long afterwards, because nothing collects them automatically and their names no longer matched anything. Those envs could not be adopted: they predate env.stamp.json and so cannot state their own ABI. Every env built since can, which is what makes adoption safe now and made it unsafe then.